diff --git a/src/test/java/roomescape/theme/util/TestThemeCreateUtil.kt b/src/test/java/roomescape/theme/util/TestThemeCreateUtil.kt new file mode 100644 index 00000000..daebd118 --- /dev/null +++ b/src/test/java/roomescape/theme/util/TestThemeCreateUtil.kt @@ -0,0 +1,44 @@ +package roomescape.theme.util + +import jakarta.persistence.EntityManager +import roomescape.member.infrastructure.persistence.Member +import roomescape.reservation.domain.ReservationStatus +import roomescape.reservation.domain.ReservationTime +import roomescape.theme.infrastructure.persistence.Theme +import roomescape.util.MemberFixture +import roomescape.util.ReservationFixture +import roomescape.util.ReservationTimeFixture +import roomescape.util.ThemeFixture +import java.time.LocalDate +import java.time.LocalTime + +object TestThemeCreateUtil { + fun createThemeWithReservations( + entityManager: EntityManager, + name: String, + reservedCount: Int, + date: LocalDate, + ): Long { + val theme: Theme = ThemeFixture.create(name = name).also { entityManager.persist(it) } + val member: Member = MemberFixture.create().also { entityManager.persist(it) } + + for (i in 1..reservedCount) { + val time: ReservationTime = ReservationTimeFixture.create( + startAt = LocalTime.now().plusMinutes(i.toLong()) + ).also { entityManager.persist(it) } + + ReservationFixture.create( + date = date, + theme = theme, + member = member, + reservationTime = time, + status = ReservationStatus.CONFIRMED + ).also { entityManager.persist(it) } + } + + entityManager.flush() + entityManager.clear() + + return theme.id + } +} diff --git a/src/test/java/roomescape/theme/web/MostReservedThemeAPITest.kt b/src/test/java/roomescape/theme/web/MostReservedThemeAPITest.kt new file mode 100644 index 00000000..f050cfaf --- /dev/null +++ b/src/test/java/roomescape/theme/web/MostReservedThemeAPITest.kt @@ -0,0 +1,112 @@ +package roomescape.theme.web + +import io.kotest.core.spec.style.FunSpec +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import jakarta.persistence.EntityManager +import org.hamcrest.Matchers.equalTo +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.transaction.support.TransactionTemplate +import roomescape.theme.business.ThemeService +import roomescape.theme.util.TestThemeCreateUtil +import java.time.LocalDate +import kotlin.random.Random + +/** + * GET /themes/most-reserved-last-week API 테스트 + * 상세 테스트는 Repository 테스트에서 진행 + * 날짜 범위, 예약 수만 검증 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MostReservedThemeAPITest( + @LocalServerPort val port: Int, + val themeService: ThemeService, + val transactionTemplate: TransactionTemplate, + val entityManager: EntityManager, +) : FunSpec() { + + init { + beforeSpec { + transactionTemplate.executeWithoutResult { + // 지난 7일간 예약된 테마 10개 생성 + for (i in 1..10) { + TestThemeCreateUtil.createThemeWithReservations( + entityManager = entityManager, + name = "테마$i", + reservedCount = 1, + date = LocalDate.now().minusDays(Random.nextLong(1, 7)) + ) + } + + // 8일 전 예약된 테마 1개 생성 + TestThemeCreateUtil.createThemeWithReservations( + entityManager = entityManager, + name = "테마11", + reservedCount = 1, + date = LocalDate.now().minusDays(8) + ) + } + } + + context("가장 많이 예약된 테마를 조회할 때,") { + val endpoint = "/themes/most-reserved-last-week" + test("갯수를 입력하지 않으면 10개를 반환한다.") { + Given { + port(port) + } When { + get(endpoint) + } Then { + log().all() + statusCode(200) + body("data.themes.size()", equalTo(10)) + } + } + + test("입력된 갯수가 조회된 갯수보다 크면 조회된 갯수만큼 반환한다.") { + val count = 15 + Given { + port(port) + } When { + param("count", count) + get("/themes/most-reserved-last-week") + } Then { + log().all() + statusCode(200) + body("data.themes.size()", equalTo(10)) + } + } + + test("입력된 갯수가 조회된 갯수보다 작으면 입력된 갯수만큼 반환한다.") { + val count = 5 + Given { + port(port) + } When { + param("count", count) + get("/themes/most-reserved-last-week") + } Then { + log().all() + statusCode(200) + body("data.themes.size()", equalTo(count)) + } + } + + test("7일 전 부터 1일 전 까지 예약된 테마를 대상으로 한다.") { + // 현재 저장된 데이터는 지난 7일간 예약된 테마 10개와 8일 전 예약된 테마 1개 + // 8일 전 예약된 테마는 제외되어야 하므로, 10개가 조회되어야 한다. + val count = 11 + Given { + port(port) + } When { + param("count", count) + get("/themes/most-reserved-last-week") + } Then { + log().all() + statusCode(200) + body("data.themes.size()", equalTo(10)) + } + } + } + } +} diff --git a/src/test/java/roomescape/theme/web/ThemeControllerTest.kt b/src/test/java/roomescape/theme/web/ThemeControllerTest.kt index 1956bce0..af3ea616 100644 --- a/src/test/java/roomescape/theme/web/ThemeControllerTest.kt +++ b/src/test/java/roomescape/theme/web/ThemeControllerTest.kt @@ -1,184 +1,307 @@ -package roomescape.theme.web; +package roomescape.theme.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.shouldContainAll +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.runs +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import roomescape.theme.business.ThemeService +import roomescape.theme.infrastructure.persistence.ThemeRepository +import roomescape.util.RoomescapeApiTest +import roomescape.util.ThemeFixture -import java.util.Map; -import java.util.stream.Stream; +@WebMvcTest(ThemeController::class) +class ThemeControllerTest(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.web.server.LocalServerPort; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + @SpykBean + private lateinit var themeService: ThemeService -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import io.restassured.http.Header; -import roomescape.member.infrastructure.persistence.Member; -import roomescape.member.infrastructure.persistence.MemberRepository; -import roomescape.member.infrastructure.persistence.Role; + @MockkBean + private lateinit var themeRepository: ThemeRepository -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) -class ThemeControllerTest { + init { + Given("모든 테마를 조회할 때") { + val endpoint = "/themes" - @LocalServerPort - private int port; + When("로그인 상태가 아니라면") { + doNotLogin() - @Autowired - private MemberRepository memberRepository; + Then("로그인 페이지로 이동한다.") { + runGetTest( + mockMvc = mockMvc, + endpoint = endpoint, + log = true + ) { + status { is3xxRedirection() } + header { + string("Location", "/login") + } + } + } + } - @Test - @DisplayName("모든 테마 정보를 조회한다.") - void readThemes() { - String email = "admin@test.com"; - String password = "12341234"; - String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password); + When("로그인 상태라면") { + loginAsUser() - RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header(new Header("Cookie", adminAccessTokenCookie)) - .port(port) - .when().get("/themes") - .then().log().all() - .statusCode(200) - .body("data.themes.size()", is(0)); - } + Then("조회에 성공한다.") { + every { + themeRepository.findAll() + } returns listOf( + ThemeFixture.create(id = 1, name = "theme1"), + ThemeFixture.create(id = 2, name = "theme2"), + ThemeFixture.create(id = 3, name = "theme3") + ) - @Test - @DisplayName("테마를 추가한다.") - void createThemes() { - String email = "admin@test.com"; - String password = "12341234"; - String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password); + val response: ThemesResponse = runGetTest( + mockMvc = mockMvc, + endpoint = endpoint, + log = true + ) { + status { isOk() } + content { + contentType(MediaType.APPLICATION_JSON) + } + }.andReturn().readValue(ThemesResponse::class.java) - Map params = Map.of( - "name", "테마명", - "description", "설명", - "thumbnail", "http://testsfasdgasd.com" - ); + assertSoftly(response.themes) { + it.size shouldBe 3 + it.map { m -> m.name } shouldContainAll listOf("theme1", "theme2", "theme3") + } + } + } + } - RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header(new Header("Cookie", adminAccessTokenCookie)) - .port(port) - .body(params) - .when().post("/themes") - .then().log().all() - .statusCode(201) - .body("data.id", is(1)) - .header("Location", "/themes/1"); - } + Given("테마를 추가할 때") { + val endpoint = "/themes" + val request = ThemeRequest( + name = "theme1", + description = "description1", + thumbnail = "http://example.com/thumbnail1.jpg" + ) - @Test - @DisplayName("테마를 삭제한다.") - void deleteThemes() { - String email = "admin@test.com"; - String password = "12341234"; - String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password); + When("로그인 상태가 아니라면") { + doNotLogin() + Then("로그인 페이지로 이동한다.") { + runPostTest( + mockMvc = mockMvc, + endpoint = endpoint, + body = request, + log = true + ) { + status { is3xxRedirection() } + header { + string("Location", "/login") + } + } + } + } - Map params = Map.of( - "name", "테마명", - "description", "설명", - "thumbnail", "http://testsfasdgasd.com" - ); + When("관리자가 아닌 회원은") { + loginAsUser() + Then("로그인 페이지로 이동한다.") { + runPostTest( + mockMvc = mockMvc, + endpoint = endpoint, + body = request, + log = true + ) { + status { is3xxRedirection() } + jsonPath("$.errorType") { value("PERMISSION_DOES_NOT_EXIST") } + } + } + } - RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header(new Header("Cookie", adminAccessTokenCookie)) - .port(port) - .body(params) - .when().post("/themes") - .then().log().all() - .statusCode(201) - .body("data.id", is(1)) - .header("Location", "/themes/1"); + When("동일한 이름의 테마가 있으면") { + loginAsAdmin() - RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header(new Header("Cookie", adminAccessTokenCookie)) - .port(port) - .when().delete("/themes/1") - .then().log().all() - .statusCode(204); - } + Then("409 에러를 응답한다.") { + every { + themeRepository.existsByName(request.name) + } returns true - /* - * reservationData DataSet ThemeID 별 reservation 개수 - * 5,4,2,5,2,3,1,1,1,1,1 - * 예약 수 내림차순 + ThemeId 오름차순 정렬 순서 - * 1, 4, 2, 6, 3, 5, 7, 8, 9, 10 - */ - @Test - @DisplayName("예약 수 상위 10개 테마를 조회했을 때 내림차순으로 정렬된다. 만약 예약 수가 같다면, id 순으로 오름차순 정렬된다.") - @Sql(scripts = {"/truncate.sql", "/reservationData.sql"}, executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) - void readTop10ThemesDescOrder() { - RestAssured.given().log().all() - .contentType(ContentType.JSON) - .port(port) - .when().get("/themes/most-reserved-last-week?count=10") - .then().log().all() - .statusCode(200) - .body("data.themes.size()", is(10)) - .body("data.themes.id", contains(1, 4, 2, 6, 3, 5, 7, 8, 9, 10)); - } + runPostTest( + mockMvc = mockMvc, + endpoint = endpoint, + body = request, + log = true + ) { + status { isConflict() } + jsonPath("$.errorType") { value("THEME_DUPLICATED") } + } + } + } - @ParameterizedTest - @MethodSource("requestValidateSource") - @DisplayName("테마 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.") - void validateBlankRequest(Map invalidRequestBody) { - String email = "admin@test.com"; - String password = "12341234"; - String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password); + When("값이 잘못 입력되면 400 에러를 응답한다") { + beforeTest { + loginAsAdmin() + } - RestAssured.given().log().all() - .contentType(ContentType.JSON) - .header(new Header("Cookie", adminAccessTokenCookie)) - .port(port) - .body(invalidRequestBody) - .when().post("/themes") - .then().log().all() - .statusCode(400); - } + val request = ThemeRequest( + name = "theme1", + description = "description1", + thumbnail = "http://example.com/thumbnail1.jpg" + ) - static Stream> requestValidateSource() { - return Stream.of( - Map.of( - "name", "테마명", - "thumbnail", "http://testsfasdgasd.com" - ), - Map.of( - "name", "", - "description", "설명", - "thumbnail", "http://testsfasdgasd.com" - ), - Map.of( - "name", " ", - "description", "설명", - "thumbnail", "http://testsfasdgasd.com" - ) - ); - } + fun runTest(request: ThemeRequest) { + runPostTest( + mockMvc = mockMvc, + endpoint = endpoint, + body = request, + log = true + ) { + status { isBadRequest() } + } + } - private String getAdminAccessTokenCookieByLogin(final String email, final String password) { - memberRepository.save(new Member(null, "이름", email, password, Role.ADMIN)); + Then("이름이 공백인 경우") { + val invalidRequest = request.copy(name = " ") + runTest(invalidRequest) + } - Map loginParams = Map.of( - "email", email, - "password", password - ); + Then("이름이 20글자를 초과하는 경우") { + val invalidRequest = request.copy(name = "a".repeat(21)) + runTest(invalidRequest) + } - String accessToken = RestAssured.given().log().all() - .contentType(ContentType.JSON) - .port(port) - .body(loginParams) - .when().post("/login") - .then().log().all().extract().cookie("accessToken"); + Then("설명이 공백인 경우") { + val invalidRequest = request.copy(description = " ") + runTest(invalidRequest) + } - return "accessToken=" + accessToken; - } + Then("설명이 100글자를 초과하는 경우") { + val invalidRequest = request.copy(description = "a".repeat(101)) + runTest(invalidRequest) + } + + Then("썸네일이 공백인 경우") { + val invalidRequest = request.copy(thumbnail = " ") + runTest(invalidRequest) + } + + Then("썸네일이 URL 형식이 아닌 경우") { + val invalidRequest = request.copy(thumbnail = "invalid-url") + runTest(invalidRequest) + } + } + + When("저장에 성공하면") { + loginAsAdmin() + + val theme = ThemeFixture.create( + id = 1, + name = request.name, + description = request.description, + thumbnail = request.thumbnail + ) + + every { + themeRepository.existsByName(request.name) + } returns false + + every { + themeRepository.save(any()) + } returns theme + + Then("201 응답을 받는다.") { + runPostTest( + mockMvc = mockMvc, + endpoint = endpoint, + body = request, + log = true + ) { + status { isCreated() } + header { + string("Location", "/themes/${theme.id}") + } + jsonPath("$.data.id") { value(theme.id) } + jsonPath("$.data.name") { value(theme.name) } + jsonPath("$.data.description") { value(theme.description) } + jsonPath("$.data.thumbnail") { value(theme.thumbnail) } + } + } + } + } + + Given("테마를 제거할 때") { + val themeId = 1L + val endpoint = "/themes/$themeId" + + When("로그인 상태가 아니라면") { + doNotLogin() + Then("로그인 페이지로 이동한다.") { + runDeleteTest( + mockMvc = mockMvc, + endpoint = endpoint, + log = true + ) { + status { is3xxRedirection() } + header { + string("Location", "/login") + } + } + } + } + + When("관리자가 아닌 회원은") { + loginAsUser() + Then("로그인 페이지로 이동한다.") { + runDeleteTest( + mockMvc = mockMvc, + endpoint = endpoint, + log = true + ) { + status { is3xxRedirection() } + jsonPath("$.errorType") { value("PERMISSION_DOES_NOT_EXIST") } + } + } + } + + When("입력된 ID에 해당하는 테마가 없으면") { + loginAsAdmin() + + Then("409 에러를 응답한다.") { + every { + themeRepository.isReservedTheme(themeId) + } returns true + + runDeleteTest( + mockMvc = mockMvc, + endpoint = endpoint, + log = true + ) { + status { isConflict() } + jsonPath("$.errorType") { value("THEME_IS_USED_CONFLICT") } + } + } + } + + When("정상적으로 제거되면") { + loginAsAdmin() + + every { + themeRepository.isReservedTheme(themeId) + } returns false + + every { + themeRepository.deleteById(themeId) + } just runs + + Then("204 응답을 받는다.") { + runDeleteTest( + mockMvc = mockMvc, + endpoint = endpoint, + log = true + ) { + status { isNoContent() } + } + } + } + } + } }