[#41] 예약 스키마 재정의 #42

Merged
pricelees merged 41 commits from refactor/#41 into main 2025-09-09 00:43:39 +00:00
18 changed files with 0 additions and 1259 deletions
Showing only changes of commit 7670e9acc1 - Show all commits

View File

@ -1,74 +0,0 @@
package roomescape.time.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.time.implement.TimeFinder
import roomescape.time.implement.TimeWriter
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.web.*
import java.time.LocalDate
import java.time.LocalTime
private val log = KotlinLogging.logger {}
@Service
class TimeService(
private val timeFinder: TimeFinder,
private val timeWriter: TimeWriter,
) {
@Transactional(readOnly = true)
fun findById(id: Long): TimeEntity {
log.debug { "[TimeService.findById] 시작: timeId=$id" }
return timeFinder.findById(id)
.also { log.info { "[TimeService.findById] 완료: timeId=$id, startAt=${it.startAt}" } }
}
@Transactional(readOnly = true)
fun findTimes(): TimeRetrieveListResponse {
log.debug { "[TimeService.findTimes] 시작" }
return timeFinder.findAll()
.toResponse()
.also { log.info { "[TimeService.findTimes] 완료. ${it.times.size}개 반환" } }
}
@Transactional(readOnly = true)
fun findTimesWithAvailability(date: LocalDate, themeId: Long): TimeWithAvailabilityListResponse {
log.debug { "[TimeService.findTimesWithAvailability] 시작: date=$date, themeId=$themeId" }
val times: List<TimeWithAvailabilityResponse> =
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
.map {
TimeWithAvailabilityResponse(
id = it.timeId,
startAt = it.startAt,
isAvailable = it.isReservable
)
}
return TimeWithAvailabilityListResponse(times)
.also { log.info { "[TimeService.findTimesWithAvailability] ${it.times.size}개 반환: date=$date, themeId=$themeId" } }
}
@Transactional
fun createTime(request: TimeCreateRequest): TimeCreateResponse {
val startAt: LocalTime = request.startAt
log.debug { "[TimeService.createTime] 시작: startAt=${startAt}" }
return timeWriter.create(startAt)
.toCreateResponse()
.also { log.info { "[TimeService.createTime] 완료: startAt=${startAt}, timeId=${it.id}" } }
}
@Transactional
fun deleteTime(id: Long) {
log.debug { "[TimeService.deleteTime] 시작: timeId=$id" }
val time: TimeEntity = timeFinder.findById(id)
timeWriter.delete(time)
.also { log.info { "[TimeService.deleteTime] 완료: timeId=$id, startAt=${time.startAt}" } }
}
}

View File

@ -1,12 +0,0 @@
package roomescape.time.business.domain
import java.time.LocalDate
import java.time.LocalTime
class TimeWithAvailability(
val timeId: Long,
val startAt: LocalTime,
val date: LocalDate,
val themeId: Long,
val isReservable: Boolean
)

View File

@ -1,50 +0,0 @@
package roomescape.time.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse
import roomescape.time.web.TimeCreateRequest
import roomescape.time.web.TimeCreateResponse
import roomescape.time.web.TimeRetrieveListResponse
import roomescape.time.web.TimeWithAvailabilityListResponse
import java.time.LocalDate
@Tag(name = "4. 예약 시간 API", description = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.")
interface TimeAPI {
@Admin
@Operation(summary = "모든 시간 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findTimes(): ResponseEntity<CommonApiResponse<TimeRetrieveListResponse>>
@Admin
@Operation(summary = "시간 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true))
fun createTime(
@Valid @RequestBody timeCreateRequest: TimeCreateRequest,
): ResponseEntity<CommonApiResponse<TimeCreateResponse>>
@Admin
@Operation(summary = "시간 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
fun deleteTime(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired
@Operation(summary = "예약 가능 여부를 포함한 모든 시간 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findTimesWithAvailability(
@RequestParam date: LocalDate,
@RequestParam themeId: Long
): ResponseEntity<CommonApiResponse<TimeWithAvailabilityListResponse>>
}

View File

@ -1,14 +0,0 @@
package roomescape.time.exception
import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode
enum class TimeErrorCode(
override val httpStatus: HttpStatus,
override val errorCode: String,
override val message: String
) : ErrorCode {
TIME_NOT_FOUND(HttpStatus.NOT_FOUND, "TM001", "시간을 찾을 수 없어요."),
TIME_DUPLICATED(HttpStatus.BAD_REQUEST, "TM002", "이미 같은 시간이 있어요."),
TIME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TM003", "예약된 시간이라 삭제할 수 없어요.")
}

View File

@ -1,9 +0,0 @@
package roomescape.time.exception
import roomescape.common.exception.ErrorCode
import roomescape.common.exception.RoomescapeException
class TimeException(
override val errorCode: ErrorCode,
override val message: String = errorCode.message
) : RoomescapeException(errorCode, message)

View File

@ -1,60 +0,0 @@
package roomescape.time.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.time.business.domain.TimeWithAvailability
import roomescape.theme.implement.ThemeFinder
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Component
class TimeFinder(
private val timeRepository: TimeRepository,
private val reservationFinder: ReservationFinder,
private val themeFinder: ThemeFinder
) {
fun findAll(): List<TimeEntity> {
log.debug { "[TimeFinder.findAll] 시작" }
return timeRepository.findAll()
.also { log.debug { "[TimeFinder.findAll] ${it.size}개 시간 조회 완료" } }
}
fun findById(id: Long): TimeEntity {
log.debug { "[TimeFinder.findById] 조회 시작: timeId=$id" }
return timeRepository.findByIdOrNull(id)
?.also { log.debug { "[TimeFinder.findById] 조회 완료: timeId=$id, startAt=${it.startAt}" } }
?: run {
log.warn { "[TimeFinder.findById] 조회 실패: timeId=$id" }
throw TimeException(TimeErrorCode.TIME_NOT_FOUND)
}
}
fun findAllWithAvailabilityByDateAndThemeId(
date: LocalDate, themeId: Long
): List<TimeWithAvailability> {
log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] 조회 시작: date:$date, themeId=$themeId" }
val theme = themeFinder.findById(themeId)
val reservations: List<ReservationEntity> = reservationFinder.findAllByDateAndTheme(date, theme)
val allTimes: List<TimeEntity> = findAll()
return allTimes.map { time ->
val isReservable: Boolean = reservations.none { reservation -> time.id == reservation.time.id }
TimeWithAvailability(time.id!!, time.startAt, date, themeId, isReservable)
}.also {
log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] ${it.size}개 조회 완료: date:$date, themeId=$themeId" }
}
}
}

View File

@ -1,41 +0,0 @@
package roomescape.time.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
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 java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class TimeValidator(
private val timeRepository: TimeRepository,
private val reservationFinder: ReservationFinder
) {
fun validateIsAlreadyExists(startAt: LocalTime) {
log.debug { "[TimeValidator.validateIsAlreadyExists] 시작: startAt=${startAt}" }
if (timeRepository.existsByStartAt(startAt)) {
log.info { "[TimeValidator.validateIsAlreadyExists] 중복 시간: startAt=$startAt" }
throw TimeException(TimeErrorCode.TIME_DUPLICATED)
}
log.debug { "[TimeValidator.validateIsAlreadyExists] 완료: startAt=${startAt}" }
}
fun validateIsReserved(time: TimeEntity) {
log.debug { "[TimeValidator.validateIsReserved] 시작: id=${time.id}, startAt=${time.startAt}" }
if (reservationFinder.isTimeReserved(time)) {
log.info { "[TimeValidator.validateIsReserved] 예약이 있는 시간: timeId=${time.id}, startAt=${time.startAt}" }
throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
}
log.debug { "[TimeValidator.validateIsReserved] 시작: id=${time.id}, startAt=${time.startAt}" }
}
}

View File

@ -1,37 +0,0 @@
package roomescape.time.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class TimeWriter(
private val timeValidator: TimeValidator,
private val timeRepository: TimeRepository,
private val tsidFactory: TsidFactory
) {
fun create(startAt: LocalTime): TimeEntity {
log.debug { "[TimeWriter.create] 시작: startAt=$startAt" }
timeValidator.validateIsAlreadyExists(startAt)
val time = TimeEntity(_id = tsidFactory.next(), startAt = startAt)
return timeRepository.save(time)
.also { log.debug { "[TimeWriter.create] 완료: startAt=$startAt, id=${it.id}" } }
}
fun delete(time: TimeEntity) {
log.debug { "[TimeWriter.delete] 시작: id=${time.id}" }
timeValidator.validateIsReserved(time)
timeRepository.delete(time)
.also { log.debug { "[TimeWriter.delete] 완료: id=${time.id}, startAt=${time.startAt}" } }
}
}

View File

@ -1,21 +0,0 @@
package roomescape.time.infrastructure.persistence
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import roomescape.common.entity.BaseEntity
import java.time.LocalTime
@Entity
@Table(name = "times")
class TimeEntity(
@Id
@Column(name = "time_id")
private var _id: Long?,
@Column(name = "start_at", nullable = false)
var startAt: LocalTime,
): BaseEntity() {
override fun getId(): Long? = _id
}

View File

@ -1,8 +0,0 @@
package roomescape.time.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalTime
interface TimeRepository : JpaRepository<TimeEntity, Long> {
fun existsByStartAt(startAt: LocalTime): Boolean
}

View File

@ -1,51 +0,0 @@
package roomescape.time.web
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import roomescape.common.dto.response.CommonApiResponse
import roomescape.time.business.TimeService
import roomescape.time.docs.TimeAPI
import java.net.URI
import java.time.LocalDate
@RestController
class TimeController(
private val timeService: TimeService
) : TimeAPI {
@GetMapping("/times")
override fun findTimes(): ResponseEntity<CommonApiResponse<TimeRetrieveListResponse>> {
val response: TimeRetrieveListResponse = timeService.findTimes()
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/times")
override fun createTime(
@Valid @RequestBody timeCreateRequest: TimeCreateRequest,
): ResponseEntity<CommonApiResponse<TimeCreateResponse>> {
val response: TimeCreateResponse = timeService.createTime(timeCreateRequest)
return ResponseEntity
.created(URI.create("/times/${response.id}"))
.body(CommonApiResponse(response))
}
@DeleteMapping("/times/{id}")
override fun deleteTime(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> {
timeService.deleteTime(id)
return ResponseEntity.noContent().build()
}
@GetMapping("/times/search")
override fun findTimesWithAvailability(
@RequestParam date: LocalDate,
@RequestParam themeId: Long
): ResponseEntity<CommonApiResponse<TimeWithAvailabilityListResponse>> {
val response: TimeWithAvailabilityListResponse = timeService.findTimesWithAvailability(date, themeId)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -1,55 +0,0 @@
package roomescape.time.web
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalTime
@Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.")
data class TimeCreateRequest(
@Schema(description = "시간", type = "string", example = "09:00")
val startAt: LocalTime
)
@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.")
data class TimeCreateResponse(
@Schema(description = "시간 식별자")
val id: Long,
@Schema(description = "시간")
val startAt: LocalTime
)
fun TimeEntity.toCreateResponse(): TimeCreateResponse = TimeCreateResponse(this.id!!, this.startAt)
data class TimeRetrieveResponse(
@Schema(description = "시간 식별자.")
val id: Long,
@Schema(description = "시간")
val startAt: LocalTime
)
fun TimeEntity.toResponse(): TimeRetrieveResponse = TimeRetrieveResponse(this.id!!, this.startAt)
data class TimeRetrieveListResponse(
val times: List<TimeRetrieveResponse>
)
fun List<TimeEntity>.toResponse(): TimeRetrieveListResponse = TimeRetrieveListResponse(
this.map { it.toResponse() }
)
data class TimeWithAvailabilityResponse(
@Schema(description = "시간 식별자")
val id: Long,
@Schema(description = "시간")
val startAt: LocalTime,
@Schema(description = "예약 가능 여부")
val isAvailable: Boolean
)
data class TimeWithAvailabilityListResponse(
val times: List<TimeWithAvailabilityResponse>
)

View File

@ -1,180 +0,0 @@
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 roomescape.time.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.implement.TimeFinder
import roomescape.time.implement.TimeWriter
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.web.TimeCreateRequest
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
class TimeServiceTest : FunSpec({
val timeFinder: TimeFinder = mockk()
val timeWriter: TimeWriter = mockk()
val timeService = TimeService(timeFinder, timeWriter)
context("findById") {
val id = 1L
test("정상 응답") {
every {
timeFinder.findById(id)
} returns TimeFixture.create(id = id)
timeService.findById(id).id shouldBe id
}
test("시간을 찾을 수 없으면 예외 응답") {
every {
timeFinder.findById(id)
} throws TimeException(TimeErrorCode.TIME_NOT_FOUND)
shouldThrow<TimeException> {
timeService.findById(id)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_NOT_FOUND
}
}
}
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.isReservable }
}
}
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("정상 응답") {
val time: TimeEntity = TimeFixture.create(startAt = request.startAt)
every {
timeWriter.create(request.startAt)
} returns time
val response = timeService.createTime(request)
response.id shouldBe time.id
}
test("중복된 시간이 있으면 예외 응답") {
every {
timeWriter.create(request.startAt)
} throws TimeException(TimeErrorCode.TIME_DUPLICATED)
shouldThrow<TimeException> {
timeService.createTime(request)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_DUPLICATED
}
}
}
context("deleteTime") {
test("정상 응답") {
val id = 1L
val time = TimeFixture.create(id = id)
every { timeFinder.findById(id) } returns time
every { timeWriter.delete(time) } just Runs
shouldNotThrow<Exception> {
timeService.deleteTime(id)
}
}
test("시간을 찾을 수 없으면 예외 응답") {
val id = 1L
every { timeFinder.findById(id) } throws TimeException(TimeErrorCode.TIME_NOT_FOUND)
shouldThrow<TimeException> {
timeService.deleteTime(id)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_NOT_FOUND
}
}
test("예약이 있는 시간이면 예외 응답") {
val id = 1L
val time = TimeFixture.create()
every { timeFinder.findById(id) } returns time
every { timeWriter.delete(time) } throws TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
shouldThrow<TimeException> {
timeService.deleteTime(id)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_ALREADY_RESERVED
}
}
}
})

View File

@ -1,144 +0,0 @@
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.reservation.infrastructure.persistence.ReservationEntity
import roomescape.time.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())
timeFinder.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.isReservable }
}
}
test("날짜, 테마에 맞는 예약이 있으면 예약할 수 없다.") {
val times = listOf(
TimeFixture.create(startAt = LocalTime.now()),
TimeFixture.create(startAt = LocalTime.now().plusMinutes(30))
)
every {
themeFinder.findById(themeId)
} returns mockk()
every {
timeRepository.findAll()
} returns times
every {
reservationFinder.findAllByDateAndTheme(date, any())
} returns listOf(
mockk<ReservationEntity>().apply {
every { time.id } returns times[0].id
},
mockk<ReservationEntity>().apply {
every { time.id } returns 0
}
)
val result: List<TimeWithAvailability> =
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
assertSoftly(result) {
it shouldHaveSize 2
it[0].isReservable shouldBe false
it[1].isReservable shouldBe true
}
}
}
})

View File

@ -1,74 +0,0 @@
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

@ -1,84 +0,0 @@
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,33 +0,0 @@
package roomescape.time.infrastructure.persistence
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.util.TimeFixture
import java.time.LocalTime
@DataJpaTest(showSql = false)
class TimeRepositoryTest(
val entityManager: EntityManager,
val timeRepository: TimeRepository,
) : FunSpec({
context("existsByStartAt") {
val startAt = LocalTime.of(10, 0)
beforeTest {
entityManager.persist(TimeFixture.create(startAt = startAt))
entityManager.flush()
entityManager.clear()
}
test("동일한 시간이 있으면 true 반환") {
timeRepository.existsByStartAt(startAt) shouldBe true
}
test("동일한 시간이 없으면 false 반환") {
timeRepository.existsByStartAt(startAt.plusSeconds(1)) shouldBe false
}
}
})

View File

@ -1,312 +0,0 @@
package roomescape.time.web
import com.ninjasquad.springmockk.MockkBean
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.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.time.business.TimeService
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.util.RoomescapeApiTest
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
@WebMvcTest(TimeController::class)
class TimeControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() {
@MockkBean
private lateinit var timeService: TimeService
init {
Given("GET /times 요청을") {
val endpoint = "/times"
When("관리자가 보내면") {
beforeTest {
loginAsAdmin()
}
Then("정상 응답") {
every {
timeService.findTimes()
} returns listOf(
TimeFixture.create(id = 1L),
TimeFixture.create(id = 2L)
).toResponse()
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
) {
status { isOk() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.data.times[0].id") { value(1) }
jsonPath("$.data.times[1].id") { value(2) }
}
}
}
}
When("관리자가 보내지 않았다면") {
loginAsUser()
val expectedError = AuthErrorCode.ACCESS_DENIED
Then("예외 응답") {
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
) {
status { isEqualTo(expectedError.httpStatus.value()) }
}.andExpect {
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { equalTo(expectedError.errorCode) }
}
}
}
}
}
Given("POST /times 요청을") {
val endpoint = "/times"
When("관리자가 보낼 때") {
beforeTest {
loginAsAdmin()
}
val time = LocalTime.of(10, 0)
val request = TimeCreateRequest(startAt = time)
Then("시간 형식이 HH:mm이 아니거나, 범위를 벗어나면 400 응답") {
listOf(
"{\"startAt\": \"23:30:30\"}",
"{\"startAt\": \"24:59\"}",
).forEach {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = it,
) {
status { isBadRequest() }
}
}
}
Then("정상 응답") {
every {
timeService.createTime(request)
} returns TimeCreateResponse(id = 1, startAt = time)
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
) {
status { isCreated() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.data.id") { value(1) }
jsonPath("$.data.startAt") { value("10:00") }
}
}
}
Then("동일한 시간이 존재하면 예외 응답") {
val expectedError = TimeErrorCode.TIME_DUPLICATED
every {
timeService.createTime(request)
} throws TimeException(expectedError)
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
) {
status { isEqualTo(expectedError.httpStatus.value()) }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { equalTo(expectedError.errorCode) }
}
}
}
}
When("관리자가 보내지 않았다면") {
loginAsUser()
Then("예외 응답") {
val expectedError = AuthErrorCode.ACCESS_DENIED
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = TimeFixture.create(),
) {
status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}
}
Given("DELETE /times/{id} 요청을") {
val endpoint = "/times/1"
When("관리자가 보낼 때") {
beforeTest {
loginAsAdmin()
}
Then("정상 응답") {
every {
timeService.deleteTime(1L)
} returns Unit
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
) {
status { isNoContent() }
}
}
Then("없는 시간을 조회하면 예외 응답") {
val id = 1L
val expectedError = TimeErrorCode.TIME_NOT_FOUND
every {
timeService.deleteTime(id)
} throws TimeException(expectedError)
runDeleteTest(
mockMvc = mockMvc,
endpoint = "/times/$id",
) {
status { isEqualTo(expectedError.httpStatus.value()) }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { equalTo(expectedError.errorCode) }
}
}
}
Then("예약이 있는 시간을 삭제하면 예외 응답") {
val id = 1L
val expectedError = TimeErrorCode.TIME_ALREADY_RESERVED
every {
timeService.deleteTime(id)
} throws TimeException(expectedError)
runDeleteTest(
mockMvc = mockMvc,
endpoint = "/times/$id",
) {
status { isEqualTo(expectedError.httpStatus.value()) }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { equalTo(expectedError.errorCode) }
}
}
}
}
When("관리자가 보내지 않았다면") {
loginAsUser()
Then("예외 응답") {
val expectedError = AuthErrorCode.ACCESS_DENIED
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
) {
status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}
}
Given("GET /times/search?date={date}&themeId={themeId} 요청을 ") {
val date: LocalDate = LocalDate.now()
val themeId = 1L
When("회원이 보낼 때") {
beforeTest {
loginAsUser()
}
Then("정상 응답") {
val response = TimeWithAvailabilityListResponse(
listOf(
TimeWithAvailabilityResponse(1L, LocalTime.of(10, 0), true),
TimeWithAvailabilityResponse(2L, LocalTime.of(10, 1), false),
TimeWithAvailabilityResponse(3L, LocalTime.of(10, 2), true)
)
)
every {
timeService.findTimesWithAvailability(date, themeId)
} returns response
val result = runGetTest(
mockMvc = mockMvc,
endpoint = "/times/search?date=$date&themeId=$themeId",
) {
status { isOk() }
content {
contentType(MediaType.APPLICATION_JSON)
}
}.andReturn().readValue(TimeWithAvailabilityListResponse::class.java)
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 response.times[1].id
this[1].isAvailable shouldBe response.times[1].isAvailable
}
}
Then("테마를 찾을 수 없으면 예외 응답") {
val expectedError = ThemeErrorCode.THEME_NOT_FOUND
every {
timeService.findTimesWithAvailability(date, themeId)
} throws ThemeException(expectedError)
runGetTest(
mockMvc = mockMvc,
endpoint = "/times/search?date=$date&themeId=$themeId",
) {
status { isNotFound() }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}
When("비회원이 보내면") {
doNotLogin()
Then("예외 응답") {
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
runGetTest(
mockMvc = mockMvc,
endpoint = "/times/search?date=$date&themeId=$themeId",
) {
status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}
}
}
}