From c6a3d77f3bc5f765d75f3f165ec2643cfe4758d0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 3 Sep 2025 10:59:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/theme/ThemeApiTest.kt | 681 ++++++++++++++++++ 1 file changed, 681 insertions(+) create mode 100644 src/test/kotlin/roomescape/theme/ThemeApiTest.kt diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt new file mode 100644 index 00000000..51017edf --- /dev/null +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -0,0 +1,681 @@ +package roomescape.theme + +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.theme.business.MIN_DURATION +import roomescape.theme.business.MIN_PARTICIPANTS +import roomescape.theme.business.MIN_PRICE +import roomescape.theme.exception.ThemeErrorCode +import roomescape.theme.infrastructure.persistence.v2.Difficulty +import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2 +import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2 +import roomescape.theme.web.ThemeCreateRequestV2 +import roomescape.theme.web.ThemeUpdateRequest +import roomescape.util.FunSpecSpringbootTest +import roomescape.util.assertProperties +import roomescape.util.runTest +import kotlin.random.Random + +class ThemeApiTest( + private val themeRepository: ThemeRepositoryV2 +) : FunSpecSpringbootTest() { + + private val request: ThemeCreateRequestV2 = ThemeCreateRequestV2( + name = "Matilda Green", + description = "constituto", + thumbnailUrl = "https://duckduckgo.com/?q=mediocrem", + difficulty = Difficulty.VERY_EASY, + price = 10000, + minParticipants = 3, + maxParticipants = 5, + availableMinutes = 80, + expectedMinutesFrom = 60, + expectedMinutesTo = 70, + isOpen = true + ) + + 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("테마 생성: POST /admin/themes") { + runTest( + token = token, + using = { + body(request) + }, + on = { + post("/admin/themes") + }, + expect = commonAssertion + ) + } + + test("테마 조회: GET /admin/themes") { + runTest( + token = token, + on = { + get("/admin/themes") + }, + expect = commonAssertion + ) + } + + test("테마 상세 조회: GET /admin/themes/{id}") { + runTest( + token = token, + on = { + get("/admin/themes/1") + }, + expect = commonAssertion + ) + } + + test("테마 수정: PATCH /admin/themes/{id}") { + runTest( + token = token, + using = { + body(request) + }, + on = { + patch("/admin/themes/1") + }, + expect = commonAssertion + ) + } + + test("테마 삭제: DELETE /admin/themes/{id}") { + runTest( + token = token, + on = { + delete("/admin/themes/1") + }, + expect = commonAssertion + ) + } + } + + context("일반 회원도 접근할 수 있다.") { + test("테마 조회: GET /v2/themes") { + createDummyTheme(request.copy(name = "test123", isOpen = true)) + + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/v2/themes") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.themes.size()", equalTo(1)) + body("data.themes[0].name", equalTo("test123")) + } + ) + } + } + + context("테마를 생성한다.") { + val apiPath = "/admin/themes" + + lateinit var token: String + + beforeTest { + token = loginUtil.loginAsAdmin() + } + + test("정상 생성 및 감사 정보 확인") { + runTest( + token = token, + using = { + body(request) + }, + on = { + post(apiPath) + }, + expect = { + statusCode(HttpStatus.CREATED.value()) + body("data.id", notNullValue()) + } + ).also { + val createdThemeId: String = it.extract().path("data.id") + val createdTheme: ThemeEntityV2 = themeRepository.findByIdOrNull(createdThemeId.toLong()) + ?: throw AssertionError("Unexpected Exception Occurred.") + + createdTheme.name shouldBe request.name + createdTheme.createdAt shouldNotBeNull {} + createdTheme.createdBy shouldNotBeNull {} + createdTheme.updatedAt shouldNotBeNull {} + createdTheme.updatedBy shouldNotBeNull {} + } + } + + test("이미 동일한 이름의 테마가 있으면 실패한다.") { + val commonName = "test123" + createDummyTheme(request.copy(name = commonName)) + + runTest( + token = token, + using = { + body(request.copy(name = commonName)) + }, + on = { + post(apiPath) + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.THEME_NAME_DUPLICATED.errorCode)) + } + ) + } + + test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { + runTest( + token = token, + using = { + body(request.copy(price = (MIN_PRICE - 1))) + }, + on = { + post(apiPath) + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.PRICE_BELOW_MINIMUM.errorCode)) + } + ) + } + + context("입력된 시간이 ${MIN_DURATION}분 미만이면 실패한다.") { + val commonAssertion: ValidatableResponse.() -> Unit = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.DURATION_BELOW_MINIMUM.errorCode)) + } + + test("field: availableMinutes") { + runTest( + token = token, + using = { + body(request.copy(availableMinutes = (MIN_DURATION - 1).toShort())) + }, + on = { + post(apiPath) + }, + expect = commonAssertion + ) + } + + test("field: expectedMinutesFrom") { + runTest( + token = token, + using = { + body(request.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort())) + }, + on = { + post(apiPath) + }, + expect = commonAssertion + ) + } + + test("field: expectedMinutesTo") { + runTest( + token = token, + using = { + body(request.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort())) + }, + on = { + post(apiPath) + }, + expect = commonAssertion + ) + } + } + + context("시간 범위가 잘못 지정되면 실패한다.") { + test("최소 예상 시간 > 최대 예상 시간") { + runTest( + token = token, + using = { + body(request.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99)) + }, + on = { + post(apiPath) + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME.errorCode)) + } + ) + } + + test("최대 예상 시간 > 이용 가능 시간") { + runTest( + token = token, + using = { + body( + request.copy( + availableMinutes = 100, + expectedMinutesFrom = 101, + expectedMinutesTo = 101 + ) + ) + }, + on = { + post(apiPath) + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME.errorCode)) + } + ) + } + } + + context("입력된 인원이 ${MIN_PARTICIPANTS}명 미만이면 실패한다.") { + val commonAssertion: ValidatableResponse.() -> Unit = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM.errorCode)) + } + + test("field: minParticipants") { + runTest( + token = token, + using = { + body(request.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort())) + }, + on = { + post(apiPath) + }, + expect = commonAssertion + ) + } + + test("field: maxParticipants") { + runTest( + token = token, + using = { + body(request.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort())) + }, + on = { + post(apiPath) + }, + expect = commonAssertion + ) + } + } + + context("인원 범위가 잘못 지정되면 실패한다.") { + test("최소 인원 > 최대 인원") { + runTest( + token = token, + using = { + body(request.copy(minParticipants = 10, maxParticipants = 9)) + }, + on = { + post(apiPath) + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT.errorCode)) + } + ) + } + } + } + + context("모든 테마를 조회한다.") { + beforeTest { + createDummyTheme(request.copy(name = "open", isOpen = true)) + createDummyTheme(request.copy(name = "close", isOpen = false)) + } + + test("관리자 페이지에서는 비공개 테마까지 포함하여 간단한 정보만 조회된다.") { + runTest( + token = loginUtil.loginAsAdmin(), + on = { + get("/admin/themes") + }, + expect = { + body("data.themes.size()", equalTo(2)) + assertProperties( + props = setOf("id", "name", "difficulty", "price", "isOpen"), + propsNameIfList = "themes", + ) + } + ) + } + + test("예약 페이지에서는 공개된 테마의 전체 정보가 조회된다.") { + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/v2/themes") + }, + expect = { + body("data.themes.size()", equalTo(1)) + body("data.themes[0].name", equalTo("open")) + assertProperties( + props = setOf( + "id", "name", "thumbnailUrl", "description", "difficulty", "price", + "minParticipants", "maxParticipants", + "availableMinutes", "expectedMinutesFrom", "expectedMinutesTo" + ), + propsNameIfList = "themes", + ) + } + ) + } + } + + context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") { + test("정상 응답") { + val createdTheme: ThemeEntityV2 = createDummyTheme(request) + + runTest( + token = loginUtil.loginAsAdmin(), + on = { + get("/admin/themes/${createdTheme.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.id", equalTo(createdTheme.id.toString())) + assertProperties( + props = setOf( + "id", "name", "description", "thumbnailUrl", "difficulty", "price", "isOpen", + "minParticipants", "maxParticipants", + "availableMinutes", "expectedMinutesFrom", "expectedMinutesTo", + "createdAt", "createdBy", "updatedAt", "updatedBy" + ) + ) + } + ) + } + + test("테마가 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsAdmin(), + on = { + get("/admin/themes/1") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode)) + } + ) + } + } + + context("테마를 삭제한다.") { + test("정상 삭제") { + val createdTheme = createDummyTheme(request) + + runTest( + token = loginUtil.loginAsAdmin(), + on = { + delete("/admin/themes/${createdTheme.id}") + }, + expect = { + statusCode(HttpStatus.NO_CONTENT.value()) + } + ).also { + themeRepository.findByIdOrNull(createdTheme.id) shouldBe null + } + } + + test("테마가 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsAdmin(), + on = { + delete("/admin/themes/1") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode)) + } + ) + } + } + + context("테마를 수정한다.") { + lateinit var token: String + lateinit var createdTheme: ThemeEntityV2 + lateinit var apiPath: String + + val updateRequest = ThemeUpdateRequest(name = "modified") + + beforeTest { + token = loginUtil.loginAsAdmin() + createdTheme = createDummyTheme(request.copy(name = "theme-${Random.nextInt()}")) + apiPath = "/admin/themes/${createdTheme.id}" + } + + test("정상 수정 및 감사 정보 변경 확인") { + val otherAdminToken = loginUtil.login("admin1@admin.com", "admin1", Role.ADMIN) + + runTest( + token = otherAdminToken, + using = { + body(updateRequest) + }, + on = { + patch(apiPath) + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedTheme = themeRepository.findByIdOrNull(createdTheme.id)!! + + updatedTheme.id shouldBe createdTheme.id + updatedTheme.name shouldBe updateRequest.name + updatedTheme.updatedBy shouldNotBe createdTheme.updatedBy + updatedTheme.updatedAt shouldBeAfter createdTheme.updatedAt + } + } + + test("테마가 없으면 실패한다.") { + runTest( + token = token, + using = { + body(updateRequest) + }, + on = { + patch("/admin/themes/1") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode)) + } + ) + } + + test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { + runTest( + token = token, + using = { + body(updateRequest.copy(price = (MIN_PRICE - 1))) + }, + on = { + patch("/admin/themes/${createdTheme.id}") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.PRICE_BELOW_MINIMUM.errorCode)) + } + ) + } + + context("입력된 시간이 ${MIN_DURATION}분 미만이면 실패한다.") { + val commonAssertion: ValidatableResponse.() -> Unit = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.DURATION_BELOW_MINIMUM.errorCode)) + } + + test("field: availableMinutes") { + runTest( + token = token, + using = { + body(updateRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort())) + }, + on = { + patch(apiPath) + }, + expect = commonAssertion + ) + } + + test("field: expectedMinutesFrom") { + runTest( + token = token, + using = { + body(updateRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort())) + }, + on = { + patch(apiPath) + }, + expect = commonAssertion + ) + } + + test("field: expectedMinutesTo") { + runTest( + token = token, + using = { + body(updateRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort())) + }, + on = { + patch(apiPath) + }, + expect = commonAssertion + ) + } + } + + context("시간 범위가 잘못 지정되면 실패한다.") { + test("최소 예상 시간 > 최대 예상 시간") { + runTest( + token = token, + using = { + body(updateRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99)) + }, + on = { + patch(apiPath) + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME.errorCode)) + } + ) + } + + test("최대 예상 시간 > 이용 가능 시간") { + runTest( + token = token, + using = { + body( + updateRequest.copy( + availableMinutes = 100, + expectedMinutesFrom = 101, + expectedMinutesTo = 101 + ) + ) + }, + on = { + patch(apiPath) + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME.errorCode)) + } + ) + } + } + + context("입력된 인원이 ${MIN_PARTICIPANTS}명 미만이면 실패한다.") { + val commonAssertion: ValidatableResponse.() -> Unit = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM.errorCode)) + } + + test("field: minParticipants") { + runTest( + token = token, + using = { + body(updateRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort())) + }, + on = { + patch(apiPath) + }, + expect = commonAssertion + ) + } + + test("field: maxParticipants") { + runTest( + token = token, + using = { + body(updateRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort())) + }, + on = { + patch(apiPath) + }, + expect = commonAssertion + ) + } + } + + context("인원 범위가 잘못 지정되면 실패한다.") { + test("최소 인원 > 최대 인원") { + runTest( + token = token, + using = { + body(updateRequest.copy(minParticipants = 10, maxParticipants = 9)) + }, + on = { + patch(apiPath) + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT.errorCode)) + } + ) + } + } + } + } + + fun createDummyTheme(request: ThemeCreateRequestV2): ThemeEntityV2 { + val createdThemeId: String = Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + header("Authorization", "Bearer ${loginUtil.loginAsAdmin()}") + body(request) + } When { + post("/admin/themes") + } Extract { + path("data.id") + } + + return themeRepository.findByIdOrNull(createdThemeId.toLong()) + ?: throw RuntimeException("unreachable line") + } +}