[#30] 코드 구조 개선 #31

Merged
pricelees merged 31 commits from refactor/#30 into main 2025-08-06 10:16:08 +00:00
6 changed files with 425 additions and 93 deletions
Showing only changes of commit ea047d38bb - Show all commits

View File

@ -16,4 +16,7 @@ class TimeWithAvailability(
startAt = startAt,
isAvailable = isReservable
)
// for test
fun canReserve(): Boolean = isReservable
}

View File

@ -1,38 +1,42 @@
package roomescape.time.business
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldNotThrow
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.theme.business.domain.TimeWithAvailability
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.time.implement.TimeFinder
import roomescape.time.implement.TimeWriter
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.web.TimeCreateRequest
import roomescape.util.TsidFactory
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
class TimeServiceTest : FunSpec({
val timeRepository: TimeRepository = mockk()
val reservationRepository: ReservationRepository = mockk()
val timeFinder: TimeFinder = mockk()
val timeWriter: TimeWriter = mockk()
val timeService = TimeService(
tsidFactory = TsidFactory,
timeRepository = timeRepository,
reservationRepository = reservationRepository
)
val timeService = TimeService(timeFinder, timeWriter)
context("findTimeById") {
context("findById") {
test("시간을 찾을 수 없으면 예외 응답") {
val id = 1L
every { timeRepository.findByIdOrNull(id) } returns null
every {
timeFinder.findById(id)
} throws TimeException(TimeErrorCode.TIME_NOT_FOUND)
shouldThrow<TimeException> {
timeService.findById(id)
@ -42,22 +46,81 @@ class TimeServiceTest : FunSpec({
}
}
context("findTimes") {
test("정상 응답") {
val times: List<TimeEntity> = listOf(
TimeFixture.create(startAt = LocalTime.now()),
TimeFixture.create(startAt = LocalTime.now().plusMinutes(1)),
TimeFixture.create(startAt = LocalTime.now().plusMinutes(2))
)
every {
timeFinder.findAll()
} returns times
val response = timeService.findTimes()
assertSoftly(response.times) {
it shouldHaveSize times.size
it.map { time -> time.startAt } shouldContainExactly times.map { time -> time.startAt }
}
}
}
context("findTimesWithAvailability") {
val date = LocalDate.now()
val themeId = 1L
test("정상 응답") {
val times: List<TimeWithAvailability> = listOf(
TimeWithAvailability(1, LocalTime.now(), date, themeId, true),
TimeWithAvailability(2, LocalTime.now().plusMinutes(1), date, themeId, false),
TimeWithAvailability(3, LocalTime.now().plusMinutes(2), date, themeId, true)
)
every {
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
} returns times
val response = timeService.findTimesWithAvailability(date, themeId)
assertSoftly(response.times) {
it shouldHaveSize times.size
it.map { time -> time.isAvailable } shouldContainExactly times.map { time -> time.canReserve() }
}
}
test("테마를 찾을 수 없으면 예외 응답") {
every {
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
} throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
shouldThrow<ThemeException> {
timeService.findTimesWithAvailability(date, themeId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
}
}
}
context("createTime") {
val request = TimeCreateRequest(startAt = LocalTime.of(10, 0))
test("정상 저장") {
every { timeRepository.existsByStartAt(request.startAt) } returns false
every { timeRepository.save(any()) } returns TimeFixture.create(
id = 1L,
startAt = request.startAt
)
val time: TimeEntity = TimeFixture.create(startAt = request.startAt)
every {
timeWriter.create(request.startAt)
} returns time
val response = timeService.createTime(request)
response.id shouldBe 1L
response.id shouldBe time.id
}
test("중복된 시간이 있으면 예외 응답") {
every { timeRepository.existsByStartAt(request.startAt) } returns true
every {
timeWriter.create(request.startAt)
} throws TimeException(TimeErrorCode.TIME_DUPLICATED)
shouldThrow<TimeException> {
timeService.createTime(request)
@ -67,14 +130,13 @@ class TimeServiceTest : FunSpec({
}
}
context("removeTimeById") {
context("deleteTime") {
test("정상 제거 및 응답") {
val id = 1L
val time = TimeFixture.create(id = id)
every { timeRepository.findByIdOrNull(id) } returns time
every { reservationRepository.findAllByTime(time) } returns emptyList()
every { timeRepository.delete(time) } just Runs
every { timeFinder.findById(id) } returns time
every { timeWriter.delete(time) } just Runs
shouldNotThrow<Exception> {
timeService.deleteTime(id)
@ -84,7 +146,7 @@ class TimeServiceTest : FunSpec({
test("시간을 찾을 수 없으면 예외 응답") {
val id = 1L
every { timeRepository.findByIdOrNull(id) } returns null
every { timeFinder.findById(id) } throws TimeException(TimeErrorCode.TIME_NOT_FOUND)
shouldThrow<TimeException> {
timeService.deleteTime(id)
@ -97,9 +159,8 @@ class TimeServiceTest : FunSpec({
val id = 1L
val time = TimeFixture.create()
every { timeRepository.findByIdOrNull(id) } returns time
every { reservationRepository.findAllByTime(time) } returns listOf(mockk())
every { timeFinder.findById(id) } returns time
every { timeWriter.delete(time) } throws TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
shouldThrow<TimeException> {
timeService.deleteTime(id)

View File

@ -0,0 +1,109 @@
package roomescape.time.implement
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.springframework.data.repository.findByIdOrNull
import roomescape.reservation.implement.ReservationFinder
import roomescape.theme.business.domain.TimeWithAvailability
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.implement.ThemeFinder
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
class TimeFinderTest : FunSpec({
val timeRepository: TimeRepository = mockk()
val reservationFinder: ReservationFinder = mockk()
val themeFinder: ThemeFinder = mockk()
val timeFinder = TimeFinder(timeRepository, reservationFinder, themeFinder)
context("findAll") {
test("모든 시간을 조회한다.") {
every {
timeRepository.findAll()
} returns listOf(mockk(), mockk(), mockk())
timeRepository.findAll() shouldHaveSize 3
}
}
context("findById") {
val timeId = 1L
test("동일한 ID인 시간을 찾아 응답한다.") {
every {
timeRepository.findByIdOrNull(timeId)
} returns mockk()
timeFinder.findById(timeId)
verify(exactly = 1) {
timeRepository.findByIdOrNull(timeId)
}
}
test("동일한 ID인 시간이 없으면 실패한다.") {
every {
timeRepository.findByIdOrNull(timeId)
} returns null
shouldThrow<TimeException> {
timeFinder.findById(timeId)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_NOT_FOUND
}
}
}
context("findAllWithAvailabilityByDateAndThemeId") {
val date = LocalDate.now()
val themeId = 1L
test("테마를 찾을 수 없으면 실패한다.") {
every {
themeFinder.findById(themeId)
} throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
shouldThrow<ThemeException> {
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
}
}
test("날짜, 테마에 맞는 예약 자체가 없으면 모든 시간이 예약 가능하다.") {
every {
themeFinder.findById(themeId)
} returns mockk()
every {
reservationFinder.findAllByDateAndTheme(date, any())
} returns emptyList()
every {
timeRepository.findAll()
} returns listOf(
TimeFixture.create(startAt = LocalTime.now()),
TimeFixture.create(startAt = LocalTime.now().plusMinutes(30))
)
val result: List<TimeWithAvailability> =
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
assertSoftly(result) {
it shouldHaveSize 2
it.all { time -> time.canReserve() }
}
}
}
})

View File

@ -0,0 +1,74 @@
package roomescape.time.implement
import io.kotest.assertions.throwables.shouldNotThrow
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import roomescape.reservation.implement.ReservationFinder
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.util.TimeFixture
import java.time.LocalTime
class TimeValidatorTest : FunSpec({
val timeRepository: TimeRepository = mockk()
val reservationFinder: ReservationFinder = mockk()
val timeValidator = TimeValidator(timeRepository, reservationFinder)
context("validateIsAlreadyExists") {
val startAt = LocalTime.now()
test("같은 이메일을 가진 회원이 있으면 예외를 던진다.") {
every {
timeRepository.existsByStartAt(startAt)
} returns true
shouldThrow<TimeException> {
timeValidator.validateIsAlreadyExists(startAt)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_DUPLICATED
}
}
test("같은 이메일을 가진 회원이 없으면 종료한다.") {
every {
timeRepository.existsByStartAt(startAt)
} returns false
shouldNotThrow<TimeException> {
timeValidator.validateIsAlreadyExists(startAt)
}
}
}
context("validateIsReserved") {
val time: TimeEntity = TimeFixture.create(startAt = LocalTime.now())
test("해당 시간에 예약이 있으면 예외를 던진다.") {
every {
reservationFinder.isTimeReserved(time)
} returns true
shouldThrow<TimeException> {
timeValidator.validateIsReserved(time)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_ALREADY_RESERVED
}
}
test("해당 시간에 예약이 없으면 종료한다.") {
every {
reservationFinder.isTimeReserved(time)
} returns false
shouldNotThrow<TimeException> {
timeValidator.validateIsReserved(time)
}
}
}
})

View File

@ -0,0 +1,84 @@
package roomescape.time.implement
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.*
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.util.TimeFixture
import roomescape.util.TsidFactory
import java.time.LocalTime
class TimeWriterTest : FunSpec({
val timeValidator: TimeValidator = mockk()
val timeRepository: TimeRepository = mockk()
val timeWriter = TimeWriter(timeValidator, timeRepository, TsidFactory)
context("create") {
val startAt = LocalTime.now()
test("중복된 시간이 있으면 실패한다.") {
every {
timeValidator.validateIsAlreadyExists(startAt)
} throws TimeException(TimeErrorCode.TIME_DUPLICATED)
shouldThrow<TimeException> {
timeWriter.create(startAt)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_DUPLICATED
}
}
test("중복된 시간이 없으면 저장한다.") {
every {
timeValidator.validateIsAlreadyExists(startAt)
} just Runs
every {
timeRepository.save(any())
} returns TimeFixture.create(startAt = startAt)
timeWriter.create(startAt)
verify(exactly = 1) {
timeRepository.save(any())
}
}
}
context("delete") {
val time: TimeEntity = TimeFixture.create()
test("예약이 있는 시간이면 실패한다.") {
every {
timeValidator.validateIsReserved(time)
} throws TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
shouldThrow<TimeException> {
timeWriter.delete(time)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_ALREADY_RESERVED
}
}
test("예약이 없는 시간이면 제거한다.") {
every {
timeValidator.validateIsReserved(time)
} just Runs
every {
timeRepository.delete(time)
} just Runs
timeWriter.delete(time)
verify(exactly = 1) {
timeRepository.delete(time)
}
}
}
})

View File

@ -1,28 +1,21 @@
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.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
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.time.exception.TimeException
import roomescape.util.RoomescapeApiTest
import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
@ -32,15 +25,9 @@ class TimeControllerTest(
val mockMvc: MockMvc,
) : RoomescapeApiTest() {
@SpykBean
@MockkBean
private lateinit var timeService: TimeService
@MockkBean
private lateinit var timeRepository: TimeRepository
@MockkBean
private lateinit var reservationRepository: ReservationRepository
init {
Given("등록된 모든 시간을 조회할 때") {
val endpoint = "/times"
@ -52,11 +39,11 @@ class TimeControllerTest(
Then("정상 응답") {
every {
timeRepository.findAll()
timeService.findTimes()
} returns listOf(
TimeFixture.create(id = 1L),
TimeFixture.create(id = 2L)
)
).toResponse()
runGetTest(
mockMvc = mockMvc,
@ -76,7 +63,7 @@ class TimeControllerTest(
loginAsUser()
val expectedError = AuthErrorCode.ACCESS_DENIED
Then("에러 응답을 받는다.") {
Then("예외 응답") {
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
@ -85,7 +72,7 @@ class TimeControllerTest(
}.andExpect {
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) }
jsonPath("$.code") { equalTo(expectedError.errorCode) }
}
}
}
@ -139,8 +126,8 @@ class TimeControllerTest(
Then("동일한 시간이 존재하면 예외 응답") {
val expectedError = TimeErrorCode.TIME_DUPLICATED
every {
timeRepository.existsByStartAt(time)
} returns true
timeService.createTime(request)
} throws TimeException(expectedError)
runPostTest(
mockMvc = mockMvc,
@ -150,7 +137,7 @@ class TimeControllerTest(
status { isEqualTo(expectedError.httpStatus.value()) }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) }
jsonPath("$.code") { equalTo(expectedError.errorCode) }
}
}
}
@ -159,7 +146,7 @@ class TimeControllerTest(
When("관리자가 아닌 경우") {
loginAsUser()
Then("에러 응답을 받는다.") {
Then("예외 응답") {
val expectedError = AuthErrorCode.ACCESS_DENIED
runPostTest(
mockMvc = mockMvc,
@ -197,9 +184,10 @@ class TimeControllerTest(
Then("없는 시간을 조회하면 예외 응답") {
val id = 1L
val expectedError = TimeErrorCode.TIME_NOT_FOUND
every {
timeRepository.findByIdOrNull(id)
} returns null
timeService.deleteTime(id)
} throws TimeException(expectedError)
runDeleteTest(
mockMvc = mockMvc,
@ -208,7 +196,7 @@ class TimeControllerTest(
status { isEqualTo(expectedError.httpStatus.value()) }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) }
jsonPath("$.code") { equalTo(expectedError.errorCode) }
}
}
}
@ -216,13 +204,10 @@ class TimeControllerTest(
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())
timeService.deleteTime(id)
} throws TimeException(expectedError)
runDeleteTest(
mockMvc = mockMvc,
@ -231,7 +216,7 @@ class TimeControllerTest(
status { isEqualTo(expectedError.httpStatus.value()) }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) }
jsonPath("$.code") { equalTo(expectedError.errorCode) }
}
}
}
@ -240,7 +225,7 @@ class TimeControllerTest(
When("관리자가 아닌 경우") {
loginAsUser()
Then("에러 응답을 받는다.") {
Then("예외 응답") {
val expectedError = AuthErrorCode.ACCESS_DENIED
runDeleteTest(
mockMvc = mockMvc,
@ -254,35 +239,34 @@ class TimeControllerTest(
}
Given("날짜, 테마가 주어졌을 때") {
loginAsUser()
beforeTest {
loginAsUser()
}
val date: LocalDate = LocalDate.now()
val themeId = 1L
When("저장된 예약 시간이 있으면") {
val times: List<TimeEntity> = 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]
When("테마를 찾을 수 있으면") {
Then("해당 날짜와 테마에 대한 예약 여부가 담긴 모든 시간을 응답") {
val response = TimeWithAvailabilityListResponse(
listOf(
TimeWithAvailabilityResponse(id = 1L, startAt = LocalTime.now(), isAvailable = true),
TimeWithAvailabilityResponse(
id = 2L,
startAt = LocalTime.now().plusMinutes(30),
isAvailable = false
),
TimeWithAvailabilityResponse(
id = 1L,
startAt = LocalTime.now().plusHours(1),
isAvailable = true
),
)
)
every {
timeService.findTimesWithAvailability(date, themeId)
} returns response
val response = runGetTest(
val result = runGetTest(
mockMvc = mockMvc,
endpoint = "/times/search?date=$date&themeId=$themeId",
) {
@ -292,16 +276,33 @@ class TimeControllerTest(
}
}.andReturn().readValue(TimeWithAvailabilityListResponse::class.java)
assertSoftly(response.times) {
this shouldHaveSize times.size
this[0].id shouldBe times[0].id
this[0].isAvailable shouldBe false
assertSoftly(result.times) {
this shouldHaveSize response.times.size
this[0].id shouldBe response.times[0].id
this[0].isAvailable shouldBe response.times[0].isAvailable
this[1].id shouldBe times[1].id
this[1].isAvailable shouldBe true
this[1].id shouldBe response.times[1].id
this[1].isAvailable shouldBe response.times[1].isAvailable
}
}
}
When("테마를 찾을 수 없으면") {
val expectedError = ThemeErrorCode.THEME_NOT_FOUND
every {
timeService.findTimesWithAvailability(date, themeId)
} throws ThemeException(expectedError)
Then("예외 응답") {
runGetTest(
mockMvc = mockMvc,
endpoint = "/times/search?date=$date&themeId=$themeId",
) {
status { isNotFound() }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}
}
}
}
}