[#37] 테마 스키마 재정의 #38

Merged
pricelees merged 13 commits from refactor/#37-1 into main 2025-09-03 02:03:37 +00:00
Showing only changes of commit c6a3d77f3b - Show all commits

View File

@ -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")
}
}