From 5fe1427fc19f5a2b5b4a671c1aa09176508f8710 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 6 Aug 2025 10:16:08 +0000 Subject: [PATCH] =?UTF-8?q?[#30]=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #30 ## ✨ 작업 내용 - ReservationService를 읽기(Find) / 쓰기(Write) 서비스로 분리 - 모든 도메인에 repository를 사용하는 Finder, Writer, Validator 도입 -> ReservationService에 있는 조회, 검증, 쓰기 작업을 별도의 클래스로 분리하기 위함이었고, 이 과정에서 다른 도메인에도 도입함. ## 🧪 테스트 새로 추가된 기능 & 클래스는 모두 테스트 추가하였고, 작업 후 전체 테스트 완료 ## 📚 참고 자료 및 기타 Reviewed-on: https://gitea.pricelees.me/pricelees/roomescape-refactored/pulls/31 Co-authored-by: pricelees Co-committed-by: pricelees --- .../roomescape/auth/business/AuthService.kt | 52 ++- .../auth/infrastructure/jwt/JwtHandler.kt | 8 + .../roomescape/auth/web/AuthController.kt | 2 +- .../auth/web/support/AuthInterceptor.kt | 8 +- .../member/business/MemberService.kt | 60 +--- .../member/implement/MemberFinder.kt | 47 +++ .../member/implement/MemberValidator.kt | 26 ++ .../member/implement/MemberWriter.kt | 35 ++ .../persistence/MemberRepository.kt | 4 +- .../payment/business/PaymentService.kt | 150 +++----- .../payment/implement/PaymentFinder.kt | 48 +++ .../payment/implement/PaymentWriter.kt | 81 +++++ .../persistence/PaymentRepository.kt | 6 +- .../roomescape/payment/web/PaymentDTO.kt | 10 +- .../business/ReservationFindService.kt | 56 +++ .../business/ReservationService.kt | 322 ------------------ .../business/ReservationWithPaymentService.kt | 42 ++- .../business/ReservationWriteService.kt | 104 ++++++ .../reservation/docs/ReservationAPI.kt | 7 +- .../implement/ReservationFinder.kt | 94 +++++ .../implement/ReservationValidator.kt | 144 ++++++++ .../implement/ReservationWriter.kt | 104 ++++++ .../persistence/ReservationEntity.kt | 7 +- .../persistence/ReservationRepository.kt | 22 +- .../ReservationSearchSpecification.kt | 4 + .../reservation/web/ReservationController.kt | 43 +-- .../reservation/web/ReservationResponse.kt | 41 ++- .../roomescape/theme/business/ThemeService.kt | 79 ++--- .../kotlin/roomescape/theme/docs/ThemeAPI.kt | 3 +- .../theme/exception/ThemeErrorCode.kt | 3 +- .../roomescape/theme/implement/ThemeFinder.kt | 47 +++ .../theme/implement/ThemeValidator.kt | 43 +++ .../roomescape/theme/implement/ThemeWriter.kt | 41 +++ .../persistence/ThemeRepository.kt | 20 +- .../roomescape/theme/web/ThemeController.kt | 4 +- .../kotlin/roomescape/theme/web/ThemeDTO.kt | 21 +- .../roomescape/time/business/TimeService.kt | 116 +++---- .../business/domain/TimeWithAvailability.kt | 12 + .../roomescape/time/implement/TimeFinder.kt | 60 ++++ .../time/implement/TimeValidator.kt | 41 +++ .../roomescape/time/implement/TimeWriter.kt | 37 ++ .../auth/business/AuthServiceTest.kt | 24 +- .../auth/infrastructure/jwt/JwtHandlerTest.kt | 2 - .../roomescape/auth/web/AuthControllerTest.kt | 44 +-- .../member/business/MemberServiceTest.kt | 100 ++++++ .../member/controller/MemberControllerTest.kt | 52 ++- .../member/implement/MemberFinderTest.kt | 85 +++++ .../member/implement/MemberValidatorTest.kt | 44 +++ .../member/implement/MemberWriterTest.kt | 65 ++++ .../persistence/MemberRepositoryTest.kt | 63 ++++ .../payment/business/PaymentServiceTest.kt | 202 ++++++----- .../payment/implement/PaymentFinderTest.kt | 93 +++++ .../payment/implement/PaymentWriterTest.kt | 121 +++++++ .../client/TossPaymentClientTest.kt | 4 +- .../persistence/PaymentRepositoryTest.kt | 52 --- .../business/ReservationCommandServiceTest.kt | 284 +++++++++++++++ .../business/ReservationQueryServiceTest.kt | 118 +++++++ .../business/ReservationServiceTest.kt | 287 ---------------- .../ReservationWithPaymentServiceTest.kt | 18 +- .../implement/ReservationFinderTest.kt | 64 ++++ .../implement/ReservationValidatorTest.kt | 170 +++++++++ .../implement/ReservationWriterTest.kt | 246 +++++++++++++ .../ReservationSearchSpecificationTest.kt | 15 + .../theme/business/ThemeServiceTest.kt | 132 ++++--- .../theme/implement/ThemeFinderTest.kt | 76 +++++ .../theme/implement/ThemeValidatorTest.kt | 79 +++++ .../theme/implement/ThemeWriterTest.kt | 86 +++++ .../persistence/ThemeRepositoryTest.kt | 39 +-- ...meCreateUtil.kt => TestThemeDataHelper.kt} | 29 +- .../theme/web/MostReservedThemeApiTest.kt | 30 +- .../theme/web/ThemeControllerTest.kt | 232 ++++++------- .../time/business/TimeServiceTest.kt | 133 ++++++-- .../time/implement/TimeFinderTest.kt | 109 ++++++ .../time/implement/TimeValidatorTest.kt | 74 ++++ .../time/implement/TimeWriterTest.kt | 84 +++++ .../roomescape/time/web/TimeControllerTest.kt | 161 ++++----- src/test/kotlin/roomescape/util/Fixtures.kt | 14 +- .../roomescape/util/RoomescapeApiTest.kt | 24 +- 78 files changed, 3962 insertions(+), 1547 deletions(-) create mode 100644 src/main/kotlin/roomescape/member/implement/MemberFinder.kt create mode 100644 src/main/kotlin/roomescape/member/implement/MemberValidator.kt create mode 100644 src/main/kotlin/roomescape/member/implement/MemberWriter.kt create mode 100644 src/main/kotlin/roomescape/payment/implement/PaymentFinder.kt create mode 100644 src/main/kotlin/roomescape/payment/implement/PaymentWriter.kt create mode 100644 src/main/kotlin/roomescape/reservation/business/ReservationFindService.kt delete mode 100644 src/main/kotlin/roomescape/reservation/business/ReservationService.kt create mode 100644 src/main/kotlin/roomescape/reservation/business/ReservationWriteService.kt create mode 100644 src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt create mode 100644 src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt create mode 100644 src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt create mode 100644 src/main/kotlin/roomescape/theme/implement/ThemeFinder.kt create mode 100644 src/main/kotlin/roomescape/theme/implement/ThemeValidator.kt create mode 100644 src/main/kotlin/roomescape/theme/implement/ThemeWriter.kt create mode 100644 src/main/kotlin/roomescape/time/business/domain/TimeWithAvailability.kt create mode 100644 src/main/kotlin/roomescape/time/implement/TimeFinder.kt create mode 100644 src/main/kotlin/roomescape/time/implement/TimeValidator.kt create mode 100644 src/main/kotlin/roomescape/time/implement/TimeWriter.kt create mode 100644 src/test/kotlin/roomescape/member/business/MemberServiceTest.kt create mode 100644 src/test/kotlin/roomescape/member/implement/MemberFinderTest.kt create mode 100644 src/test/kotlin/roomescape/member/implement/MemberValidatorTest.kt create mode 100644 src/test/kotlin/roomescape/member/implement/MemberWriterTest.kt create mode 100644 src/test/kotlin/roomescape/member/infrastructure/persistence/MemberRepositoryTest.kt create mode 100644 src/test/kotlin/roomescape/payment/implement/PaymentFinderTest.kt create mode 100644 src/test/kotlin/roomescape/payment/implement/PaymentWriterTest.kt create mode 100644 src/test/kotlin/roomescape/reservation/business/ReservationCommandServiceTest.kt create mode 100644 src/test/kotlin/roomescape/reservation/business/ReservationQueryServiceTest.kt delete mode 100644 src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt create mode 100644 src/test/kotlin/roomescape/reservation/implement/ReservationFinderTest.kt create mode 100644 src/test/kotlin/roomescape/reservation/implement/ReservationValidatorTest.kt create mode 100644 src/test/kotlin/roomescape/reservation/implement/ReservationWriterTest.kt create mode 100644 src/test/kotlin/roomescape/theme/implement/ThemeFinderTest.kt create mode 100644 src/test/kotlin/roomescape/theme/implement/ThemeValidatorTest.kt create mode 100644 src/test/kotlin/roomescape/theme/implement/ThemeWriterTest.kt rename src/test/kotlin/roomescape/theme/util/{TestThemeCreateUtil.kt => TestThemeDataHelper.kt} (58%) create mode 100644 src/test/kotlin/roomescape/time/implement/TimeFinderTest.kt create mode 100644 src/test/kotlin/roomescape/time/implement/TimeValidatorTest.kt create mode 100644 src/test/kotlin/roomescape/time/implement/TimeWriterTest.kt diff --git a/src/main/kotlin/roomescape/auth/business/AuthService.kt b/src/main/kotlin/roomescape/auth/business/AuthService.kt index b28e6c7c..e2f23227 100644 --- a/src/main/kotlin/roomescape/auth/business/AuthService.kt +++ b/src/main/kotlin/roomescape/auth/business/AuthService.kt @@ -3,64 +3,56 @@ package roomescape.auth.business import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginResponse -import roomescape.common.exception.RoomescapeException -import roomescape.member.business.MemberService +import roomescape.member.implement.MemberFinder import roomescape.member.infrastructure.persistence.MemberEntity private val log: KLogger = KotlinLogging.logger {} @Service class AuthService( - private val memberService: MemberService, + private val memberFinder: MemberFinder, private val jwtHandler: JwtHandler, ) { + @Transactional(readOnly = true) fun login(request: LoginRequest): LoginResponse { val params = "email=${request.email}, password=${request.password}" - log.debug { "[AuthService.login] 로그인 시작: $params" } + log.debug { "[AuthService.login] 시작: $params" } - val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.LOGIN_FAILED, params, "login") { - memberService.findByEmailAndPassword(request.email, request.password) + val member: MemberEntity = fetchOrThrow(AuthErrorCode.LOGIN_FAILED) { + memberFinder.findByEmailAndPassword(request.email, request.password) } - val accessToken: String = jwtHandler.createToken(member.id!!) + return LoginResponse(accessToken) - .also { log.info { "[AuthService.login] 로그인 완료: memberId=${member.id}" } } + .also { log.info { "[AuthService.login] 완료: email=${request.email}, memberId=${member.id}" } } } + @Transactional(readOnly = true) fun checkLogin(memberId: Long): LoginCheckResponse { - log.debug { "[AuthService.checkLogin] 로그인 확인 시작: memberId=$memberId" } - val member: MemberEntity = - fetchMemberOrThrow(AuthErrorCode.MEMBER_NOT_FOUND, "memberId=$memberId", "checkLogin") { - memberService.findById(memberId) - } + log.debug { "[AuthService.checkLogin] 시작: memberId=$memberId" } + + val member: MemberEntity = fetchOrThrow(AuthErrorCode.MEMBER_NOT_FOUND) { memberFinder.findById(memberId) } return LoginCheckResponse(member.name, member.role.name) - .also { log.info { "[AuthService.checkLogin] 로그인 확인 완료: memberId=$memberId" } } + .also { log.info { "[AuthService.checkLogin] 완료: memberId=$memberId, role=${it.role}" } } + } + + private fun fetchOrThrow(errorCode: AuthErrorCode, block: () -> MemberEntity): MemberEntity { + try { + return block() + } catch (e: Exception) { + throw AuthException(errorCode, e.message ?: errorCode.message) + } } fun logout(memberId: Long) { log.info { "[AuthService.logout] 로그아웃: memberId=$memberId" } } - - private fun fetchMemberOrThrow( - errorCode: AuthErrorCode, - params: String, - calledBy: String, - block: () -> MemberEntity, - ): MemberEntity { - try { - return block() - } catch (e: Exception) { - if (e !is RoomescapeException) { - log.warn(e) { "[AuthService.$calledBy] 회원 조회 실패: $params" } - } - throw AuthException(errorCode) - } - } } diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt index 0e224cb2..42ef933c 100644 --- a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt @@ -1,5 +1,7 @@ package roomescape.auth.infrastructure.jwt +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys @@ -10,6 +12,8 @@ import roomescape.auth.exception.AuthException import java.util.* import javax.crypto.SecretKey +private val log: KLogger = KotlinLogging.logger {} + @Component class JwtHandler( @Value("\${security.jwt.token.secret-key}") @@ -21,6 +25,7 @@ class JwtHandler( private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray()) fun createToken(memberId: Long): String { + log.debug { "[JwtHandler.createToken] 시작: memberId=$memberId" } val date = Date() val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000)) @@ -30,10 +35,12 @@ class JwtHandler( .expiration(accessTokenExpiredAt) .signWith(secretKey) .compact() + .also { log.debug { "[JwtHandler.createToken] 완료. memberId=$memberId, token=$it" } } } fun getMemberIdFromToken(token: String?): Long { try { + log.debug { "[JwtHandler.getMemberIdFromToken] 시작: token=$token" } return Jwts.parser() .verifyWith(secretKey) .build() @@ -41,6 +48,7 @@ class JwtHandler( .payload .get(MEMBER_ID_CLAIM_KEY, Number::class.java) .toLong() + .also { log.debug { "[JwtHandler.getMemberIdFromToken] 완료. memberId=$it, token=$token" } } } catch (_: IllegalArgumentException) { throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) } catch (_: ExpiredJwtException) { diff --git a/src/main/kotlin/roomescape/auth/web/AuthController.kt b/src/main/kotlin/roomescape/auth/web/AuthController.kt index 7f3f1cc8..5b9184f7 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthController.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthController.kt @@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController -import roomescape.auth.docs.AuthAPI import roomescape.auth.business.AuthService +import roomescape.auth.docs.AuthAPI import roomescape.auth.web.support.MemberId import roomescape.common.dto.response.CommonApiResponse diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt index 9b7dadb8..a4f3742b 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt @@ -11,14 +11,14 @@ import org.springframework.web.servlet.HandlerInterceptor import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtHandler -import roomescape.member.business.MemberService +import roomescape.member.implement.MemberFinder import roomescape.member.infrastructure.persistence.MemberEntity private val log: KLogger = KotlinLogging.logger {} @Component class AuthInterceptor( - private val memberService: MemberService, + private val memberFinder: MemberFinder, private val jwtHandler: JwtHandler ) : HandlerInterceptor { override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { @@ -50,10 +50,10 @@ class AuthInterceptor( private fun findMember(accessToken: String?): MemberEntity { try { val memberId = jwtHandler.getMemberIdFromToken(accessToken) - return memberService.findById(memberId) + return memberFinder.findById(memberId) .also { MDC.put("member_id", "$memberId") } } catch (e: Exception) { - log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = ${accessToken}" } + log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = $accessToken" } val errorCode = AuthErrorCode.MEMBER_NOT_FOUND throw AuthException(errorCode, e.message ?: errorCode.message) } diff --git a/src/main/kotlin/roomescape/member/business/MemberService.kt b/src/main/kotlin/roomescape/member/business/MemberService.kt index d1a9e0ed..1ab7afa2 100644 --- a/src/main/kotlin/roomescape/member/business/MemberService.kt +++ b/src/main/kotlin/roomescape/member/business/MemberService.kt @@ -1,72 +1,44 @@ package roomescape.member.business -import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.config.next -import roomescape.member.exception.MemberErrorCode -import roomescape.member.exception.MemberException +import roomescape.member.implement.MemberFinder +import roomescape.member.implement.MemberWriter import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.Role import roomescape.member.web.* private val log = KotlinLogging.logger {} @Service -@Transactional(readOnly = true) class MemberService( - private val tsidFactory: TsidFactory, - private val memberRepository: MemberRepository, + private val memberWriter: MemberWriter, + private val memberFinder: MemberFinder, ) { + @Transactional(readOnly = true) fun findMembers(): MemberRetrieveListResponse { - log.debug { "[MemberService.findMembers] 회원 조회 시작" } + log.debug { "[MemberService.findMembers] 시작" } - return memberRepository.findAll() - .also { log.info { "[MemberService.findMembers] 회원 ${it.size}명 조회 완료" } } + return memberFinder.findAll() .toRetrieveListResponse() + .also { log.info { "[MemberService.findMembers] 완료. ${it.members.size}명 반환" } } } + @Transactional(readOnly = true) fun findById(memberId: Long): MemberEntity { - return fetchOrThrow("findById", "memberId=$memberId") { - memberRepository.findByIdOrNull(memberId) - } - } + log.debug { "[MemberService.findById] 시작" } - fun findByEmailAndPassword(email: String, password: String): MemberEntity { - return fetchOrThrow("findByEmailAndPassword", "email=$email, password=$password") { - memberRepository.findByEmailAndPassword(email, password) - } + return memberFinder.findById(memberId) + .also { log.info { "[MemberService.findById] 완료. memberId=${memberId}, email=${it.email}" } } } @Transactional fun createMember(request: SignupRequest): SignupResponse { - memberRepository.findByEmail(request.email)?.let { - log.info { "[MemberService.createMember] 회원가입 실패(이메일 중복): email=${request.email}" } - throw MemberException(MemberErrorCode.DUPLICATE_EMAIL) - } + log.debug { "[MemberService.createMember] 시작" } - val member = MemberEntity( - _id = tsidFactory.next(), - name = request.name, - email = request.email, - password = request.password, - role = Role.MEMBER - ) - - return memberRepository.save(member).toSignupResponse() - .also { log.info { "[MemberService.create] 회원가입 완료: email=${request.email} memberId=${it.id}" } } - } - - private fun fetchOrThrow(calledBy: String, params: String, block: () -> MemberEntity?): MemberEntity { - log.debug { "[MemberService.$calledBy] 회원 조회 시작: $params" } - return block() - ?.also { log.info { "[MemberService.$calledBy] 회원 조회 완료: memberId=${it.id}" } } - ?: run { - log.info { "[MemberService.$calledBy] 회원 조회 실패: $params" } - throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) - } + return memberWriter.create(request.name, request.email, request.password, Role.MEMBER) + .toSignupResponse() + .also { log.info { "[MemberService.create] 완료: email=${request.email} memberId=${it.id}" } } } } diff --git a/src/main/kotlin/roomescape/member/implement/MemberFinder.kt b/src/main/kotlin/roomescape/member/implement/MemberFinder.kt new file mode 100644 index 00000000..4c68dfcf --- /dev/null +++ b/src/main/kotlin/roomescape/member/implement/MemberFinder.kt @@ -0,0 +1,47 @@ +package roomescape.member.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.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.member.infrastructure.persistence.MemberRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class MemberFinder( + private val memberRepository: MemberRepository +) { + + fun findAll(): List { + log.debug { "[MemberFinder.findAll] 회원 조회 시작" } + + return memberRepository.findAll() + .also { log.debug { "[MemberFinder.findAll] 회원 ${it.size}명 조회 완료" } } + } + + fun findById(id: Long): MemberEntity { + log.debug { "[MemberFinder.findById] 조회 시작: memberId=$id" } + + return memberRepository.findByIdOrNull(id) + ?.also { log.debug { "[MemberFinder.findById] 조회 완료: memberId=$id, email=${it.email}" } } + ?: run { + log.info { "[MemberFinder.findById] 조회 실패: id=$id" } + throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) + } + } + + fun findByEmailAndPassword(email: String, password: String): MemberEntity { + log.debug { "[MemberFinder.findByEmailAndPassword] 조회 시작: email=$email, password=$password" } + + return memberRepository.findByEmailAndPassword(email, password) + ?.also { log.debug { "[MemberFinder.findByEmailAndPassword] 조회 완료: email=${email}, memberId=${it.id}" } } + ?: run { + log.info { "[MemberFinder.findByEmailAndPassword] 조회 실패: email=${email}, password=${password}" } + throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/member/implement/MemberValidator.kt b/src/main/kotlin/roomescape/member/implement/MemberValidator.kt new file mode 100644 index 00000000..1b3c54d2 --- /dev/null +++ b/src/main/kotlin/roomescape/member/implement/MemberValidator.kt @@ -0,0 +1,26 @@ +package roomescape.member.implement + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class MemberValidator( + private val memberRepository: MemberRepository +) { + fun validateCanSignup(email: String) { + log.debug { "[MemberValidator.validateCanSignup] 시작: email=$email" } + + if (memberRepository.existsByEmail(email)) { + log.info { "[MemberValidator.validateCanSignup] 중복 이메일: email=$email" } + throw MemberException(MemberErrorCode.DUPLICATE_EMAIL) + } + + log.debug { "[MemberValidator.validateCanSignup] 완료: email=$email" } + } +} diff --git a/src/main/kotlin/roomescape/member/implement/MemberWriter.kt b/src/main/kotlin/roomescape/member/implement/MemberWriter.kt new file mode 100644 index 00000000..34a7b7b9 --- /dev/null +++ b/src/main/kotlin/roomescape/member/implement/MemberWriter.kt @@ -0,0 +1,35 @@ +package roomescape.member.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.member.infrastructure.persistence.MemberEntity +import roomescape.member.infrastructure.persistence.MemberRepository +import roomescape.member.infrastructure.persistence.Role + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class MemberWriter( + private val tsidFactory: TsidFactory, + private val memberValidator: MemberValidator, + private val memberRepository: MemberRepository +) { + fun create(name: String, email: String, password: String, role: Role): MemberEntity { + log.debug { "[MemberWriter.create] 시작: email=$email" } + memberValidator.validateCanSignup(email) + + val member = MemberEntity( + _id = tsidFactory.next(), + name = name, + email = email, + password = password, + role = role + ) + + return memberRepository.save(member) + .also { log.debug { "[MemberWriter.create] 완료: email=$email, memberId=${it.id}" } } + } +} diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt index 13e2ac6d..5b5fd828 100644 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt @@ -3,7 +3,7 @@ package roomescape.member.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository interface MemberRepository : JpaRepository { - fun findByEmailAndPassword(email: String, password: String): MemberEntity? + fun existsByEmail(email: String): Boolean - fun findByEmail(email: String): MemberEntity? + fun findByEmailAndPassword(email: String, password: String): MemberEntity? } diff --git a/src/main/kotlin/roomescape/payment/business/PaymentService.kt b/src/main/kotlin/roomescape/payment/business/PaymentService.kt index b5458b69..b560ec06 100644 --- a/src/main/kotlin/roomescape/payment/business/PaymentService.kt +++ b/src/main/kotlin/roomescape/payment/business/PaymentService.kt @@ -1,17 +1,13 @@ package roomescape.payment.business -import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.config.next -import roomescape.payment.exception.PaymentErrorCode -import roomescape.payment.exception.PaymentException +import roomescape.payment.implement.PaymentFinder +import roomescape.payment.implement.PaymentWriter import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity -import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository import roomescape.payment.infrastructure.persistence.PaymentEntity -import roomescape.payment.infrastructure.persistence.PaymentRepository import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCreateResponse @@ -23,108 +19,70 @@ private val log = KotlinLogging.logger {} @Service class PaymentService( - private val tsidFactory: TsidFactory, - private val paymentRepository: PaymentRepository, - private val canceledPaymentRepository: CanceledPaymentRepository, + private val paymentFinder: PaymentFinder, + private val paymentWriter: PaymentWriter ) { - @Transactional - fun createPayment( - approveResponse: PaymentApproveResponse, - reservation: ReservationEntity, - ): PaymentCreateResponse { - log.debug { "[PaymentService.createPayment] 결제 정보 저장 시작: request=$approveResponse, reservationId=${reservation.id}" } - val payment = PaymentEntity( - _id = tsidFactory.next(), - orderId = approveResponse.orderId, - paymentKey = approveResponse.paymentKey, - totalAmount = approveResponse.totalAmount, - reservation = reservation, - approvedAt = approveResponse.approvedAt - ) + @Transactional(readOnly = true) + fun existsByReservationId(reservationId: Long): Boolean { + log.debug { "[PaymentService.existsByReservationId] 시작: reservationId=$reservationId" } - return paymentRepository.save(payment) - .toCreateResponse() - .also { log.info { "[PaymentService.createPayment] 결제 정보 저장 완료: paymentId=${it.id}, reservationId=${reservation.id}" } } + return paymentFinder.existsPaymentByReservationId(reservationId) + .also { log.info { "[PaymentService.existsByReservationId] 완료: reservationId=$reservationId, isPaid=$it" } } } - @Transactional(readOnly = true) - fun isReservationPaid(reservationId: Long): Boolean { - log.debug { "[PaymentService.isReservationPaid] 예약 결제 여부 확인 시작: reservationId=$reservationId" } + @Transactional + fun createPayment( + approvedPaymentInfo: PaymentApproveResponse, + reservation: ReservationEntity, + ): PaymentCreateResponse { + log.debug { "[PaymentService.createPayment] 시작: paymentKey=${approvedPaymentInfo.paymentKey}, reservationId=${reservation.id}" } - return paymentRepository.existsByReservationId(reservationId) - .also { log.info { "[PaymentService.isReservationPaid] 예약 결제 여부 확인 완료: reservationId=$reservationId, isPaid=$it" } } + val created: PaymentEntity = paymentWriter.create( + paymentKey = approvedPaymentInfo.paymentKey, + orderId = approvedPaymentInfo.orderId, + totalAmount = approvedPaymentInfo.totalAmount, + approvedAt = approvedPaymentInfo.approvedAt, + reservation = reservation + ) + + return created.toCreateResponse() + .also { log.info { "[PaymentService.createPayment] 완료: paymentKey=${it.paymentKey}, reservationId=${reservation.id}, paymentId=${it.id}" } } } @Transactional fun createCanceledPayment( - cancelInfo: PaymentCancelResponse, + canceledPaymentInfo: PaymentCancelResponse, approvedAt: OffsetDateTime, paymentKey: String, ): CanceledPaymentEntity { - log.debug { - "[PaymentService.createCanceledPayment] 결제 취소 정보 저장 시작: paymentKey=$paymentKey" + - ", cancelInfo=$cancelInfo" - } - val canceledPayment = CanceledPaymentEntity( - _id = tsidFactory.next(), - paymentKey = paymentKey, - cancelReason = cancelInfo.cancelReason, - cancelAmount = cancelInfo.cancelAmount, + log.debug { "[PaymentService.createCanceledPayment] 시작: paymentKey=$paymentKey" } + + val created: CanceledPaymentEntity = paymentWriter.createCanceled( + cancelReason = canceledPaymentInfo.cancelReason, + cancelAmount = canceledPaymentInfo.cancelAmount, + canceledAt = canceledPaymentInfo.canceledAt, approvedAt = approvedAt, - canceledAt = cancelInfo.canceledAt + paymentKey = paymentKey ) - return canceledPaymentRepository.save(canceledPayment) - .also { - log.info { - "[PaymentService.createCanceledPayment] 결제 취소 정보 생성 완료: canceledPaymentId=${it.id}" + - ", paymentKey=${paymentKey}, amount=${cancelInfo.cancelAmount}, canceledAt=${it.canceledAt}" - } - } + return created.also { + log.info { "[PaymentService.createCanceledPayment] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" } + } } @Transactional - fun createCanceledPaymentByReservationId(reservationId: Long): PaymentCancelRequest { - log.debug { "[PaymentService.createCanceledPaymentByReservationId] 예약 삭제 & 결제 취소 정보 저장 시작: reservationId=$reservationId" } - val paymentKey: String = paymentRepository.findPaymentKeyByReservationId(reservationId) - ?: run { - log.warn { "[PaymentService.createCanceledPaymentByReservationId] 예약 조회 실패: reservationId=$reservationId" } - throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) - } + fun createCanceledPayment(reservationId: Long): PaymentCancelRequest { + log.debug { "[PaymentService.createCanceledPayment] 시작: reservationId=$reservationId" } - val canceled: CanceledPaymentEntity = cancelPayment(paymentKey) - - return PaymentCancelRequest(paymentKey, canceled.cancelAmount, canceled.cancelReason) - .also { log.info { "[PaymentService.createCanceledPaymentByReservationId] 예약 ID로 결제 취소 완료: reservationId=$reservationId" } } - } - - private fun cancelPayment( - paymentKey: String, - cancelReason: String = "고객 요청", - canceledAt: OffsetDateTime = OffsetDateTime.now(), - ): CanceledPaymentEntity { - log.debug { "[PaymentService.cancelPayment] 결제 취소 정보 저장 시작: paymentKey=$paymentKey" } - val payment: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey) - ?.also { - paymentRepository.delete(it) - log.info { "[PaymentService.cancelPayment] 결제 정보 삭제 완료: paymentId=${it.id}, paymentKey=$paymentKey" } - } - ?: run { - log.warn { "[PaymentService.cancelPayment] 결제 정보 조회 실패: paymentKey=$paymentKey" } - throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) - } - - val canceledPayment = CanceledPaymentEntity( - _id = tsidFactory.next(), - paymentKey = paymentKey, - cancelReason = cancelReason, - cancelAmount = payment.totalAmount, - approvedAt = payment.approvedAt, - canceledAt = canceledAt + val payment: PaymentEntity = paymentFinder.findByReservationId(reservationId) + val canceled: CanceledPaymentEntity = paymentWriter.createCanceled( + payment = payment, + cancelReason = "예약 취소", + canceledAt = OffsetDateTime.now(), ) - return canceledPaymentRepository.save(canceledPayment) - .also { log.info { "[PaymentService.cancelPayment] 결제 취소 정보 저장 완료: canceledPaymentId=${it.id}" } } + return PaymentCancelRequest(canceled.paymentKey, canceled.cancelAmount, canceled.cancelReason) + .also { log.info { "[PaymentService.createCanceledPayment] 완료: reservationId=$reservationId, paymentKey=${it.paymentKey}" } } } @Transactional @@ -132,18 +90,10 @@ class PaymentService( paymentKey: String, canceledAt: OffsetDateTime, ) { - log.debug { "[PaymentService.updateCanceledTime] 취소 시간 업데이트 시작: paymentKey=$paymentKey, canceledAt=$canceledAt" } - canceledPaymentRepository.findByPaymentKey(paymentKey) - ?.apply { this.canceledAt = canceledAt } - ?.also { - log.info { - "[PaymentService.updateCanceledTime] 취소 시간 업데이트 완료: paymentKey=$paymentKey" + - ", canceledAt=$canceledAt" - } - } - ?: run { - log.warn { "[PaymentService.updateCanceledTime] 결제 정보 조회 실패: paymentKey=$paymentKey" } - throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) - } + log.debug { "[PaymentService.updateCanceledTime] 시작: paymentKey=$paymentKey, canceledAt=$canceledAt" } + + paymentFinder.findCanceledByKey(paymentKey).apply { this.canceledAt = canceledAt } + + log.info { "[PaymentService.updateCanceledTime] 완료: paymentKey=$paymentKey, canceledAt=$canceledAt" } } } diff --git a/src/main/kotlin/roomescape/payment/implement/PaymentFinder.kt b/src/main/kotlin/roomescape/payment/implement/PaymentFinder.kt new file mode 100644 index 00000000..84a64d17 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/implement/PaymentFinder.kt @@ -0,0 +1,48 @@ +package roomescape.payment.implement + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException +import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import roomescape.payment.infrastructure.persistence.PaymentEntity +import roomescape.payment.infrastructure.persistence.PaymentRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class PaymentFinder( + private val paymentRepository: PaymentRepository, + private val canceledPaymentRepository: CanceledPaymentRepository, +) { + fun existsPaymentByReservationId(reservationId: Long): Boolean { + log.debug { "[PaymentFinder.existsPaymentByReservationId] 시작: reservationId=$reservationId" } + + return paymentRepository.existsByReservationId(reservationId) + .also { log.debug { "[PaymentFinder.existsPaymentByReservationId] 완료: reservationId=$reservationId, isExist=$it" } } + } + + fun findByReservationId(reservationId: Long): PaymentEntity { + log.debug { "[PaymentFinder.findByReservationId] 시작: reservationId=$reservationId" } + + return paymentRepository.findByReservationId(reservationId) + ?.also { log.debug { "[PaymentFinder.findByReservationId] 완료: reservationId=$reservationId" } } + ?: run { + log.warn { "[PaymentFinder.findByReservationId] 실패: reservationId=$reservationId" } + throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) + } + } + + fun findCanceledByKey(paymentKey: String): CanceledPaymentEntity { + log.debug { "[PaymentFinder.findCanceledByKey] 시작: paymentKey=$paymentKey" } + + return canceledPaymentRepository.findByPaymentKey(paymentKey) + ?.also { log.debug { "[PaymentFinder.findCanceledByKey] 완료: canceledPaymentId=${it.id}" } } + ?: run { + log.warn { "[PaymentFinder.findCanceledByKey] 실패: paymentKey=$paymentKey" } + throw PaymentException(PaymentErrorCode.CANCELED_PAYMENT_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/payment/implement/PaymentWriter.kt b/src/main/kotlin/roomescape/payment/implement/PaymentWriter.kt new file mode 100644 index 00000000..be815940 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/implement/PaymentWriter.kt @@ -0,0 +1,81 @@ +package roomescape.payment.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.payment.infrastructure.persistence.CanceledPaymentEntity +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import roomescape.payment.infrastructure.persistence.PaymentEntity +import roomescape.payment.infrastructure.persistence.PaymentRepository +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import java.time.OffsetDateTime + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class PaymentWriter( + private val paymentRepository: PaymentRepository, + private val canceledPaymentRepository: CanceledPaymentRepository, + private val tsidFactory: TsidFactory, +) { + fun create( + paymentKey: String, + orderId: String, + totalAmount: Long, + approvedAt: OffsetDateTime, + reservation: ReservationEntity + ): PaymentEntity { + log.debug { "[PaymentWriter.create] 시작: paymentKey=${paymentKey}, reservationId=${reservation.id}" } + + val payment = PaymentEntity( + _id = tsidFactory.next(), + orderId = orderId, + paymentKey = paymentKey, + totalAmount = totalAmount, + reservation = reservation, + approvedAt = approvedAt + ) + + return paymentRepository.save(payment) + .also { log.debug { "[PaymentWriter.create] 완료: paymentId=${it.id}, reservationId=${reservation.id}" } } + } + + fun createCanceled( + payment: PaymentEntity, + cancelReason: String, + canceledAt: OffsetDateTime, + ): CanceledPaymentEntity = createCanceled( + cancelReason = cancelReason, + canceledAt = canceledAt, + cancelAmount = payment.totalAmount, + approvedAt = payment.approvedAt, + paymentKey = payment.paymentKey + ) + + fun createCanceled( + cancelReason: String, + cancelAmount: Long, + canceledAt: OffsetDateTime, + approvedAt: OffsetDateTime, + paymentKey: String, + ): CanceledPaymentEntity { + log.debug { "[PaymentWriter.createCanceled] 시작: paymentKey=$paymentKey cancelAmount=$cancelAmount" } + + val canceledPayment = CanceledPaymentEntity( + _id = tsidFactory.next(), + paymentKey = paymentKey, + cancelReason = cancelReason, + cancelAmount = cancelAmount, + approvedAt = approvedAt, + canceledAt = canceledAt + ) + + return canceledPaymentRepository.save(canceledPayment) + .also { + paymentRepository.deleteByPaymentKey(paymentKey) + log.debug { "[PaymentWriter.createCanceled] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" } + } + } +} diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepository.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepository.kt index bf83f375..52180c4d 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepository.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepository.kt @@ -1,14 +1,12 @@ package roomescape.payment.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query interface PaymentRepository : JpaRepository { fun existsByReservationId(reservationId: Long): Boolean - @Query("SELECT p.paymentKey FROM PaymentEntity p WHERE p.reservation.id = :reservationId") - fun findPaymentKeyByReservationId(reservationId: Long): String? + fun findByReservationId(reservationId: Long): PaymentEntity? - fun findByPaymentKey(paymentKey: String): PaymentEntity? + fun deleteByPaymentKey(paymentKey: String) } diff --git a/src/main/kotlin/roomescape/payment/web/PaymentDTO.kt b/src/main/kotlin/roomescape/payment/web/PaymentDTO.kt index 10a885fe..44c069e9 100644 --- a/src/main/kotlin/roomescape/payment/web/PaymentDTO.kt +++ b/src/main/kotlin/roomescape/payment/web/PaymentDTO.kt @@ -3,8 +3,6 @@ package roomescape.payment.web import com.fasterxml.jackson.databind.annotation.JsonDeserialize import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer import roomescape.payment.infrastructure.persistence.PaymentEntity -import roomescape.reservation.web.ReservationRetrieveResponse -import roomescape.reservation.web.toRetrieveResponse import java.time.OffsetDateTime data class PaymentCancelRequest( @@ -26,15 +24,15 @@ data class PaymentCreateResponse( val orderId: String, val paymentKey: String, val totalAmount: Long, - val reservation: ReservationRetrieveResponse, + val reservationId: Long, val approvedAt: OffsetDateTime ) -fun PaymentEntity.toCreateResponse(): PaymentCreateResponse = PaymentCreateResponse( +fun PaymentEntity.toCreateResponse() = PaymentCreateResponse( id = this.id!!, orderId = this.orderId, paymentKey = this.paymentKey, totalAmount = this.totalAmount, - reservation = this.reservation.toRetrieveResponse(), + reservationId = this.reservation.id!!, approvedAt = this.approvedAt -) \ No newline at end of file +) diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationFindService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationFindService.kt new file mode 100644 index 00000000..3b2bf390 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/business/ReservationFindService.kt @@ -0,0 +1,56 @@ +package roomescape.reservation.business + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.reservation.implement.ReservationFinder +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.MyReservationRetrieveListResponse +import roomescape.reservation.web.ReservationRetrieveListResponse +import roomescape.reservation.web.toRetrieveListResponse +import java.time.LocalDate + +private val log = KotlinLogging.logger {} + +@Service +@Transactional(readOnly = true) +class ReservationFindService( + private val reservationFinder: ReservationFinder +) { + fun findReservations(): ReservationRetrieveListResponse { + log.debug { "[ReservationService.findReservations] 시작" } + + return reservationFinder.findAllByStatuses(*ReservationStatus.confirmedStatus()) + .toRetrieveListResponse() + .also { log.info { "[ReservationService.findReservations] ${it.reservations.size}개의 예약 조회 완료" } } + } + + fun findAllWaiting(): ReservationRetrieveListResponse { + log.debug { "[ReservationService.findAllWaiting] 시작" } + + return reservationFinder.findAllByStatuses(ReservationStatus.WAITING) + .toRetrieveListResponse() + .also { log.info { "[ReservationService.findAllWaiting] ${it.reservations.size}개의 대기 조회 완료" } } + } + + fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse { + log.debug { "[ReservationService.findReservationsByMemberId] 시작: memberId=$memberId" } + + return reservationFinder.findAllByMemberId(memberId) + .toRetrieveListResponse() + .also { log.info { "[ReservationService.findReservationsByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=$memberId" } } + } + + fun searchReservations( + themeId: Long?, + memberId: Long?, + startFrom: LocalDate?, + endAt: LocalDate?, + ): ReservationRetrieveListResponse { + log.debug { "[ReservationService.searchReservations] 시작: themeId=$themeId, memberId=$memberId, dateFrom=$startFrom, dateTo=$endAt" } + + return reservationFinder.searchReservations(themeId, memberId, startFrom, endAt) + .toRetrieveListResponse() + .also { log.info { "[ReservationService.searchReservations] ${it.reservations.size}개의 예약 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$startFrom, dateTo=$endAt" } } + } +} diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt deleted file mode 100644 index d22be309..00000000 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ /dev/null @@ -1,322 +0,0 @@ -package roomescape.reservation.business - -import com.github.f4b6a3.tsid.TsidFactory -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.data.jpa.domain.Specification -import org.springframework.data.repository.findByIdOrNull -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import roomescape.common.config.next -import roomescape.member.business.MemberService -import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.reservation.exception.ReservationErrorCode -import roomescape.reservation.exception.ReservationException -import roomescape.reservation.infrastructure.persistence.ReservationEntity -import roomescape.reservation.infrastructure.persistence.ReservationRepository -import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification -import roomescape.reservation.infrastructure.persistence.ReservationStatus -import roomescape.reservation.web.* -import roomescape.theme.business.ThemeService -import roomescape.theme.infrastructure.persistence.ThemeEntity -import roomescape.time.business.TimeService -import roomescape.time.infrastructure.persistence.TimeEntity -import java.time.LocalDate -import java.time.LocalDateTime - -private val log = KotlinLogging.logger {} - -@Service -@Transactional -class ReservationService( - private val tsidFactory: TsidFactory, - private val reservationRepository: ReservationRepository, - private val timeService: TimeService, - private val memberService: MemberService, - private val themeService: ThemeService, -) { - - @Transactional(readOnly = true) - fun findReservations(): ReservationRetrieveListResponse { - val spec: Specification = ReservationSearchSpecification() - .confirmed() - .build() - val reservations = findAllReservationByStatus(spec) - log.info { "[ReservationService.findReservations] ${reservations.size} 개의 확정 예약 조회 완료" } - - return ReservationRetrieveListResponse(reservations) - } - - @Transactional(readOnly = true) - fun findAllWaiting(): ReservationRetrieveListResponse { - val spec: Specification = ReservationSearchSpecification() - .waiting() - .build() - val reservations = findAllReservationByStatus(spec) - log.info { "[ReservationService.findAllWaiting] ${reservations.size} 개의 대기 예약 조회 완료" } - - return ReservationRetrieveListResponse(reservations) - } - - private fun findAllReservationByStatus(spec: Specification): List { - return reservationRepository.findAll(spec).map { it.toRetrieveResponse() } - } - - fun deleteReservation(reservationId: Long, memberId: Long) { - validateIsMemberAdmin(memberId, "deleteReservation") - log.debug { "[ReservationService.deleteReservation] 예약 삭제 시작: reservationId=$reservationId, memberId=$memberId" } - reservationRepository.deleteById(reservationId) - log.info { "[ReservationService.deleteReservation] 예약 삭제 완료: reservationId=$reservationId" } - } - - fun createConfirmedReservation( - request: ReservationCreateWithPaymentRequest, - memberId: Long, - ): ReservationEntity { - val themeId = request.themeId - val timeId = request.timeId - val date: LocalDate = request.date - validateIsReservationExist(themeId, timeId, date, "createConfirmedReservation") - - log.debug { "[ReservationService.createConfirmedReservation] 예약 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" } - val reservation: ReservationEntity = - createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED) - - return reservationRepository.save(reservation) - .also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.status}" } } - } - - fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse { - validateIsReservationExist(request.themeId, request.timeId, request.date) - - log.debug { "[ReservationService.createReservationByAdmin] 관리자의 예약 추가: memberId=${request.memberId}, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" } - return addReservationWithoutPayment( - request.themeId, - request.timeId, - request.date, - request.memberId, - ReservationStatus.CONFIRMED_PAYMENT_REQUIRED - ).also { - log.info { "[ReservationService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" } - } - } - - fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationRetrieveResponse { - validateMemberAlreadyReserve(request.themeId, request.timeId, request.date, memberId) - log.debug { "[ReservationService.createWaiting] 예약 대기 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" } - return addReservationWithoutPayment( - request.themeId, - request.timeId, - request.date, - memberId, - ReservationStatus.WAITING - ).also { - log.info { "[ReservationService.createWaiting] 예약 대기 추가 완료: reservationId=${it.id}, status=${it.status}" } - } - } - - private fun addReservationWithoutPayment( - themeId: Long, - timeId: Long, - date: LocalDate, - memberId: Long, - status: ReservationStatus, - ): ReservationRetrieveResponse = createEntity(timeId, themeId, date, memberId, status) - .also { - reservationRepository.save(it) - }.toRetrieveResponse() - - private fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, memberId: Long) { - log.debug { - "[ReservationService.validateMemberAlreadyReserve] 회원의 중복 예약 여부 확인: themeId=$themeId, timeId=$timeId, date=$date, memberId=$memberId" - } - val spec: Specification = ReservationSearchSpecification() - .sameMemberId(memberId) - .sameThemeId(themeId) - .sameTimeId(timeId) - .sameDate(date) - .build() - - if (reservationRepository.exists(spec)) { - log.warn { "[ReservationService.validateMemberAlreadyReserve] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" } - throw ReservationException(ReservationErrorCode.ALREADY_RESERVE) - } - } - - private fun validateIsReservationExist( - themeId: Long, - timeId: Long, - date: LocalDate, - calledBy: String = "validateIsReservationExist" - ) { - log.debug { - "[ReservationService.$calledBy] 예약 존재 여부 확인: themeId=$themeId, timeId=$timeId, date=$date" - } - val spec: Specification = ReservationSearchSpecification() - .confirmed() - .sameThemeId(themeId) - .sameTimeId(timeId) - .sameDate(date) - .build() - - if (reservationRepository.exists(spec)) { - log.warn { "[ReservationService.$calledBy] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" } - throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED) - } - } - - private fun validateDateAndTime( - requestDate: LocalDate, - requestTime: TimeEntity, - ) { - val now = LocalDateTime.now() - val request = LocalDateTime.of(requestDate, requestTime.startAt) - - if (request.isBefore(now)) { - log.info { "[ReservationService.validateDateAndTime] 날짜 범위 오류. request=$request, now=$now" } - throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME) - } - } - - private fun createEntity( - timeId: Long, - themeId: Long, - date: LocalDate, - memberId: Long, - status: ReservationStatus, - ): ReservationEntity { - val time: TimeEntity = timeService.findById(timeId) - val theme: ThemeEntity = themeService.findById(themeId) - val member: MemberEntity = memberService.findById(memberId) - - validateDateAndTime(date, time) - - return ReservationEntity( - _id = tsidFactory.next(), - date = date, - time = time, - theme = theme, - member = member, - status = status - ) - } - - @Transactional(readOnly = true) - fun searchReservations( - themeId: Long?, - memberId: Long?, - dateFrom: LocalDate?, - dateTo: LocalDate?, - ): ReservationRetrieveListResponse { - log.debug { "[ReservationService.searchReservations] 예약 검색 시작: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" } - validateSearchDateRange(dateFrom, dateTo) - val spec: Specification = ReservationSearchSpecification() - .confirmed() - .sameThemeId(themeId) - .sameMemberId(memberId) - .dateStartFrom(dateFrom) - .dateEndAt(dateTo) - .build() - val reservations = findAllReservationByStatus(spec) - - return ReservationRetrieveListResponse(reservations) - .also { log.info { "[ReservationService.searchReservations] 예약 ${reservations.size}개 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" } } - } - - private fun validateSearchDateRange(startFrom: LocalDate?, endAt: LocalDate?) { - if (startFrom == null || endAt == null) { - return - } - if (startFrom.isAfter(endAt)) { - log.info { "[ReservationService.validateSearchDateRange] 조회 범위 오류: startFrom=$startFrom, endAt=$endAt" } - throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE) - } - } - - @Transactional(readOnly = true) - fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse { - val reservations = reservationRepository.findAllByMemberId(memberId) - log.info { "[ReservationService.findReservationsByMemberId] memberId=${memberId}인 ${reservations.size}개의 예약 조회 완료" } - return MyReservationRetrieveListResponse(reservations) - } - - fun confirmWaiting(reservationId: Long, memberId: Long) { - log.debug { "[ReservationService.confirmWaiting] 대기 예약 승인 시작: reservationId=$reservationId (by adminId=$memberId)" } - validateIsMemberAdmin(memberId, "confirmWaiting") - - log.debug { "[ReservationService.confirmWaiting] 대기 여부 확인 시작: reservationId=$reservationId" } - if (reservationRepository.isExistConfirmedReservation(reservationId)) { - log.warn { "[ReservationService.confirmWaiting] 승인 실패(이미 확정된 예약 존재): reservationId=$reservationId" } - throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS) - } - - log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 시작: reservationId=$reservationId" } - reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) - log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" } - - log.info { "[ReservationService.confirmWaiting] 대기 예약 승인 완료: reservationId=$reservationId" } - } - - fun deleteWaiting(reservationId: Long, memberId: Long) { - log.debug { "[ReservationService.deleteWaiting] 대기 취소 시작: reservationId=$reservationId, memberId=$memberId" } - - val reservation: ReservationEntity = findReservationOrThrow(reservationId, "deleteWaiting") - if (!reservation.isWaiting()) { - log.warn { - "[ReservationService.deleteWaiting] 대기 취소 실패(대기 예약이 아님): reservationId=$reservationId" + - ", currentStatus=${reservation.status} memberId=$memberId" - } - throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) - } - if (!reservation.isReservedBy(memberId)) { - log.error { - "[ReservationService.deleteWaiting] 대기 취소 실패(예약자 본인의 취소 요청이 아님): reservationId=$reservationId" + - ", memberId=$memberId " - } - throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) - } - log.debug { "[ReservationService.deleteWaiting] 대기 예약 삭제 시작: reservationId=$reservationId" } - reservationRepository.delete(reservation) - - log.info { "[ReservationService.deleteWaiting] 대기 취소 완료: reservationId=$reservationId, memberId=$memberId" } - } - - fun rejectWaiting(reservationId: Long, memberId: Long) { - validateIsMemberAdmin(memberId, "rejectWaiting") - log.debug { "[ReservationService.rejectWaiting] 대기 예약 삭제 시작: reservationId=$reservationId (by adminId=$memberId)" } - val reservation: ReservationEntity = findReservationOrThrow(reservationId, "rejectWaiting") - - if (!reservation.isWaiting()) { - log.warn { - "[ReservationService.rejectWaiting] 대기 예약 삭제 실패(이미 확정 상태): reservationId=$reservationId" + - ", status=${reservation.status}" - } - throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) - } - reservationRepository.delete(reservation) - log.info { "[ReservationService.rejectWaiting] 대기 예약 삭제 완료: reservationId=$reservationId" } - } - - private fun validateIsMemberAdmin(memberId: Long, calledBy: String = "validateIsMemberAdmin") { - log.debug { "[ReservationService.$calledBy] 관리자 여부 확인: memberId=$memberId" } - val member: MemberEntity = memberService.findById(memberId) - if (member.isAdmin()) { - return - } - log.warn { "[ReservationService.$calledBy] 관리자가 아님: memberId=$memberId, role=${member.role}" } - throw ReservationException(ReservationErrorCode.NO_PERMISSION) - } - - private fun findReservationOrThrow( - reservationId: Long, - calledBy: String = "findReservationOrThrow" - ): ReservationEntity { - log.debug { "[ReservationService.$calledBy] 예약 조회: reservationId=$reservationId" } - return reservationRepository.findByIdOrNull(reservationId) - ?.also { log.info { "[ReservationService.$calledBy] 예약 조회 완료: reservationId=$reservationId" } } - ?: run { - log.warn { "[ReservationService.$calledBy] 예약 조회 실패: reservationId=$reservationId" } - throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) - } - - } -} diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentService.kt index ed260498..3c50e5de 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentService.kt @@ -8,8 +8,9 @@ import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.web.ReservationCreateResponse import roomescape.reservation.web.ReservationCreateWithPaymentRequest -import roomescape.reservation.web.ReservationRetrieveResponse +import roomescape.reservation.web.toCreateResponse import java.time.OffsetDateTime private val log = KotlinLogging.logger {} @@ -17,47 +18,52 @@ private val log = KotlinLogging.logger {} @Service @Transactional class ReservationWithPaymentService( - private val reservationService: ReservationService, + private val reservationWriteService: ReservationWriteService, private val paymentService: PaymentService, ) { fun createReservationAndPayment( request: ReservationCreateWithPaymentRequest, - paymentInfo: PaymentApproveResponse, + approvedPaymentInfo: PaymentApproveResponse, memberId: Long, - ): ReservationRetrieveResponse { - log.info { "[ReservationWithPaymentService.createReservationAndPayment] 예약 & 결제 정보 저장 시작: memberId=$memberId, paymentInfo=$paymentInfo" } - val reservation: ReservationEntity = reservationService.createConfirmedReservation(request, memberId) + ): ReservationCreateResponse { + log.info { "[ReservationWithPaymentService.createReservationAndPayment] 시작: memberId=$memberId, paymentInfo=$approvedPaymentInfo" } - return paymentService.createPayment(paymentInfo, reservation) - .also { log.info { "[ReservationWithPaymentService.createReservationAndPayment] 예약 & 결제 정보 저장 완료: reservationId=${reservation.id}, paymentId=${it.id}" } } - .reservation + val reservation: ReservationEntity = reservationWriteService.createReservationWithPayment(request, memberId) + .also { paymentService.createPayment(approvedPaymentInfo, it) } + + return reservation.toCreateResponse() + .also { log.info { "[ReservationWithPaymentService.createReservationAndPayment] 완료: reservationId=${reservation.id}, paymentId=${it.id}" } } } fun createCanceledPayment( - cancelInfo: PaymentCancelResponse, + canceledPaymentInfo: PaymentCancelResponse, approvedAt: OffsetDateTime, paymentKey: String, ) { - paymentService.createCanceledPayment(cancelInfo, approvedAt, paymentKey) + paymentService.createCanceledPayment(canceledPaymentInfo, approvedAt, paymentKey) } fun deleteReservationAndPayment( reservationId: Long, memberId: Long, ): PaymentCancelRequest { - log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 시작: reservationId=$reservationId" } - val paymentCancelRequest = paymentService.createCanceledPaymentByReservationId(reservationId) + log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 시작: reservationId=$reservationId" } + val paymentCancelRequest = paymentService.createCanceledPayment(reservationId) - reservationService.deleteReservation(reservationId, memberId) - log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 완료: reservationId=$reservationId" } + reservationWriteService.deleteReservation(reservationId, memberId) + log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 완료: reservationId=$reservationId" } return paymentCancelRequest } @Transactional(readOnly = true) fun isNotPaidReservation(reservationId: Long): Boolean { - log.debug { "[ReservationWithPaymentService.isNotPaidReservation] 예약 결제 여부 확인: reservationId=$reservationId" } - return !paymentService.isReservationPaid(reservationId) - .also { log.info { "[ReservationWithPaymentService.isNotPaidReservation] 결제 여부 확인 완료: reservationId=$reservationId, 결제 여부=${!it}" } } + log.info { "[ReservationWithPaymentService.isNotPaidReservation] 시작: reservationId=$reservationId" } + + val notPaid: Boolean = !paymentService.existsByReservationId(reservationId) + + return notPaid.also { + log.info { "[ReservationWithPaymentService.isNotPaidReservation] 완료: reservationId=$reservationId, isPaid=${notPaid}" } + } } fun updateCanceledTime( diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationWriteService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationWriteService.kt new file mode 100644 index 00000000..56cbe099 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/business/ReservationWriteService.kt @@ -0,0 +1,104 @@ +package roomescape.reservation.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.transaction.Transactional +import org.springframework.stereotype.Service +import roomescape.reservation.implement.ReservationFinder +import roomescape.reservation.implement.ReservationWriter +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.* + +private val log: KLogger = KotlinLogging.logger {} + +@Service +@Transactional +class ReservationWriteService( + private val reservationFinder: ReservationFinder, + private val reservationWriter: ReservationWriter +) { + fun createReservationWithPayment( + request: ReservationCreateWithPaymentRequest, + memberId: Long + ): ReservationEntity { + log.debug { "[ReservationCommandService.createReservationByAdmin] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${memberId}" } + + val created: ReservationEntity = reservationWriter.create( + date = request.date, + timeId = request.timeId, + themeId = request.themeId, + status = ReservationStatus.CONFIRMED, + memberId = memberId, + requesterId = memberId + ) + + return created.also { + log.info { "[ReservationCommandService.createReservationByAdmin] 완료: reservationId=${it.id}" } + } + } + + fun createReservationByAdmin( + request: AdminReservationCreateRequest, + memberId: Long + ): ReservationCreateResponse { + log.debug { "[ReservationCommandService.createReservationByAdmin] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${request.memberId} by adminId=${memberId}" } + + val created: ReservationEntity = reservationWriter.create( + date = request.date, + timeId = request.timeId, + themeId = request.themeId, + status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED, + memberId = request.memberId, + requesterId = memberId + ) + + return created.toCreateResponse() + .also { + log.info { "[ReservationCommandService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" } + } + } + + fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationCreateResponse { + log.debug { "[ReservationCommandService.createWaiting] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${memberId}" } + + val created: ReservationEntity = reservationWriter.create( + date = request.date, + timeId = request.timeId, + themeId = request.themeId, + status = ReservationStatus.WAITING, + memberId = memberId, + requesterId = memberId + ) + + return created.toCreateResponse() + .also { + log.info { "[ReservationCommandService.createWaiting] 완료: reservationId=${it.id}" } + } + } + + fun deleteReservation(reservationId: Long, memberId: Long) { + log.debug { "[ReservationCommandService.deleteReservation] 시작: reservationId=${reservationId}, memberId=$memberId" } + + val reservation: ReservationEntity = reservationFinder.findById(reservationId) + + reservationWriter.deleteConfirmed(reservation, requesterId = memberId) + .also { log.info { "[ReservationCommandService.deleteReservation] 완료: reservationId=${reservationId}" } } + } + + fun confirmWaiting(reservationId: Long, memberId: Long) { + log.debug { "[ReservationCommandService.confirmWaiting] 시작: reservationId=$reservationId (by adminId=$memberId)" } + + reservationWriter.confirm(reservationId) + .also { log.info { "[ReservationCommandService.confirmWaiting] 완료: reservationId=$reservationId" } } + } + + fun deleteWaiting(reservationId: Long, memberId: Long) { + log.debug { "[ReservationCommandService.deleteWaiting] 시작: reservationId=$reservationId (by adminId=$memberId)" } + + val reservation: ReservationEntity = reservationFinder.findById(reservationId) + + reservationWriter.deleteWaiting(reservation, requesterId = memberId) + .also { log.info { "[ReservationCommandService.deleteWaiting] 완료: reservationId=$reservationId" } } + } +} diff --git a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt index d8187401..fb5b7dd3 100644 --- a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt +++ b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt @@ -74,7 +74,7 @@ interface ReservationAPI { fun createReservationWithPayment( @Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest, @MemberId @Parameter(hidden = true) memberId: Long - ): ResponseEntity> + ): ResponseEntity> @Admin @Operation(summary = "관리자 예약 추가", tags = ["관리자 로그인이 필요한 API"]) @@ -92,7 +92,8 @@ interface ReservationAPI { ) fun createReservationByAdmin( @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest, - ): ResponseEntity> + @MemberId @Parameter(hidden = true) memberId: Long + ): ResponseEntity> @Admin @Operation(summary = "모든 예약 대기 조회", tags = ["관리자 로그인이 필요한 API"]) @@ -116,7 +117,7 @@ interface ReservationAPI { fun createWaiting( @Valid @RequestBody waitingCreateRequest: WaitingCreateRequest, @MemberId @Parameter(hidden = true) memberId: Long, - ): ResponseEntity> + ): ResponseEntity> @LoginRequired @Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"]) diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt new file mode 100644 index 00000000..adf824d7 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt @@ -0,0 +1,94 @@ +package roomescape.reservation.implement + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.jpa.domain.Specification +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.MyReservationRetrieveResponse +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.infrastructure.persistence.TimeEntity +import java.time.LocalDate + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ReservationFinder( + private val reservationRepository: ReservationRepository, + private val reservationValidator: ReservationValidator, +) { + fun findById(id: Long): ReservationEntity { + log.debug { "[ReservationFinder.findById] 시작: id=$id" } + + return reservationRepository.findByIdOrNull(id) + ?.also { log.debug { "[ReservationFinder.findById] 완료: reservationId=$id, date:${it.date}, timeId:${it.time.id}, themeId:${it.theme.id}" } } + ?: run { + log.warn { "[ReservationFinder.findById] 조회 실패: reservationId=$id" } + throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) + } + } + + fun findAllByStatuses(vararg statuses: ReservationStatus): List { + log.debug { "[ReservationFinder.findAll] 시작: status=${statuses}" } + + val spec = ReservationSearchSpecification() + .status(*statuses) + .build() + + return reservationRepository.findAll(spec) + .also { log.debug { "[ReservationFinder.findAll] ${it.size}개 예약 조회 완료: status=${statuses}" } } + } + + fun findAllByDateAndTheme( + date: LocalDate, theme: ThemeEntity + ): List { + log.debug { "[ReservationFinder.findAllByDateAndTheme] 시작: date=$date, themeId=${theme.id}" } + + return reservationRepository.findAllByDateAndTheme(date, theme) + .also { log.debug { "[ReservationFinder.findAllByDateAndTheme] ${it.size}개 조회 완료: date=$date, themeId=${theme.id}" } } + } + + fun findAllByMemberId(memberId: Long): List { + log.debug { "[ReservationFinder.findAllByMemberId] 시작: memberId=${memberId}" } + + return reservationRepository.findAllByMemberId(memberId) + .also { log.debug { "[ReservationFinder.findAllByMemberId] ${it.size}개 예약(대기) 조회 완료: memberId=${memberId}" } } + } + + fun searchReservations( + themeId: Long?, + memberId: Long?, + startFrom: LocalDate?, + endAt: LocalDate?, + ): List { + reservationValidator.validateSearchDateRange(startFrom, endAt) + + val spec: Specification = ReservationSearchSpecification() + .sameThemeId(themeId) + .sameMemberId(memberId) + .dateStartFrom(startFrom) + .dateEndAt(endAt) + .status(ReservationStatus.CONFIRMED, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) + .build() + + return reservationRepository.findAll(spec) + .also { + log.debug { "[ReservationFinder.searchReservations] ${it.size}개 예약 조회 완료. " + + "themeId=${themeId}, memberId=${memberId}, startFrom=${startFrom}, endAt=${endAt}" } + } + + } + + fun isTimeReserved(time: TimeEntity): Boolean { + log.debug { "[ReservationFinder.isTimeReserved] 시작: timeId=${time.id}, startAt=${time.startAt}" } + + return reservationRepository.existsByTime(time) + .also { log.debug { "[ReservationFinder.isTimeReserved] 완료: isExist=$it, timeId=${time.id}, startAt=${time.startAt}" } } + } +} diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt new file mode 100644 index 00000000..257c9815 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt @@ -0,0 +1,144 @@ +package roomescape.reservation.implement + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.jpa.domain.Specification +import org.springframework.stereotype.Component +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.infrastructure.persistence.TimeEntity +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ReservationValidator( + private val reservationRepository: ReservationRepository, +) { + fun validateIsPast( + requestDate: LocalDate, + requestTime: LocalTime, + ) { + val now = LocalDateTime.now() + val requestDateTime = LocalDateTime.of(requestDate, requestTime) + log.debug { "[ReservationValidator.validateIsPast] 시작. request=$requestDateTime, now=$now" } + + if (requestDateTime.isBefore(now)) { + log.info { "[ReservationValidator.validateIsPast] 날짜 범위 오류. request=$requestDateTime, now=$now" } + throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME) + } + + log.debug { "[ReservationValidator.validateIsPast] 완료. request=$requestDateTime, now=$now" } + } + + fun validateSearchDateRange(startFrom: LocalDate?, endAt: LocalDate?) { + log.debug { "[ReservationValidator.validateSearchDateRange] 시작: startFrom=$startFrom, endAt=$endAt" } + if (startFrom == null || endAt == null) { + log.debug { "[ReservationValidator.validateSearchDateRange] 완료: startFrom=$startFrom, endAt=$endAt" } + return + } + if (startFrom.isAfter(endAt)) { + log.info { "[ReservationValidator.validateSearchDateRange] 날짜 범위 오류: startFrom=$startFrom, endAt=$endAt" } + throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE) + } + log.debug { "[ReservationValidator.validateSearchDateRange] 완료: startFrom=$startFrom, endAt=$endAt" } + } + + fun validateIsAlreadyExists(date: LocalDate, time: TimeEntity, theme: ThemeEntity) { + val themeId = theme.id + val timeId = time.id + + log.debug { "[ReservationValidator.validateIsAlreadyExists] 시작: date=$date, timeId=$timeId, themeId=$themeId" } + + val spec: Specification = ReservationSearchSpecification() + .sameThemeId(themeId) + .sameTimeId(timeId) + .sameDate(date) + .build() + + if (reservationRepository.exists(spec)) { + log.warn { "[ReservationValidator.validateIsAlreadyExists] 중복된 예약 존재: date=$date, timeId=$timeId, themeId=$themeId" } + throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED) + } + + log.debug { "[ReservationValidator.validateIsAlreadyExists] 완료: date=$date, timeId=$timeId, themeId=$themeId" } + } + + fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, requesterId: Long) { + log.debug { "[ReservationValidator.validateMemberAlreadyReserve] 시작: themeId=$themeId, timeId=$timeId, date=$date, requesterId=$requesterId" } + + val spec: Specification = ReservationSearchSpecification() + .sameMemberId(requesterId) + .sameThemeId(themeId) + .sameTimeId(timeId) + .sameDate(date) + .build() + + if (reservationRepository.exists(spec)) { + log.warn { "[ReservationValidator.validateMemberAlreadyReserve] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" } + throw ReservationException(ReservationErrorCode.ALREADY_RESERVE) + } + + log.debug { "[ReservationValidator.validateMemberAlreadyReserve] 완료: themeId=$themeId, timeId=$timeId, date=$date, requesterId=$requesterId" } + } + + fun validateIsWaiting(reservation: ReservationEntity) { + log.debug { "[ReservationValidator.validateIsWaiting] 시작: reservationId=${reservation.id}, status=${reservation.status}" } + + if (!reservation.isWaiting()) { + log.warn { "[ReservationValidator.validateIsWaiting] 대기 상태가 아님: reservationId=${reservation.id}, status=${reservation.status}" } + throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) + } + + log.debug { "[ReservationValidator.validateIsWaiting] 완료: reservationId=${reservation.id}, status=${reservation.status}" } + } + + fun validateCreateAuthority(requester: MemberEntity) { + log.debug { "[ReservationValidator.validateCreateAuthority] 시작: requesterId=${requester.id}" } + + if (!requester.isAdmin()) { + log.error { "[ReservationValidator.validateCreateAuthority] 관리자가 아닌 다른 회원의 예약 시도: requesterId=${requester.id}" } + throw ReservationException(ReservationErrorCode.NO_PERMISSION) + } + + log.debug { "[ReservationValidator.validateCreateAuthority] 완료: requesterId=${requester.id}" } + } + + fun validateDeleteAuthority(reservation: ReservationEntity, requester: MemberEntity) { + val requesterId: Long = requester.id!! + log.debug { "[ReservationValidator.validateDeleteAuthority] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" } + + if (requester.isAdmin()) { + log.debug { "[ReservationValidator.validateDeleteAuthority] 완료: reservationId=${reservation.id} requesterId=${requesterId}(Admin)" } + return + } + + if (!reservation.isReservedBy(requesterId)) { + log.error { + "[ReservationValidator.validateDeleteAuthority] 예약자 본인이 아님: reservationId=${reservation.id}" + + ", memberId=${reservation.member.id} requesterId=${requesterId} " + } + throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) + } + + log.debug { "[ReservationValidator.validateDeleteAuthority] 완료: reservationId=${reservation.id}, requesterId=$requesterId" } + } + + fun validateAlreadyConfirmed(reservationId: Long) { + log.debug { "[ReservationValidator.validateAlreadyConfirmed] 시작: reservationId=$reservationId" } + + if (reservationRepository.isExistConfirmedReservation(reservationId)) { + log.warn { "[ReservationWriter.confirm] 이미 확정된 예약: reservationId=$reservationId" } + throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS) + } + + log.debug { "[ReservationValidator.validateAlreadyConfirmed] 완료: reservationId=$reservationId" } + } +} diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt new file mode 100644 index 00000000..23f34de8 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt @@ -0,0 +1,104 @@ +package roomescape.reservation.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.member.implement.MemberFinder +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.theme.implement.ThemeFinder +import roomescape.time.implement.TimeFinder +import java.time.LocalDate + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ReservationWriter( + private val reservationValidator: ReservationValidator, + private val reservationRepository: ReservationRepository, + private val memberFinder: MemberFinder, + private val timeFinder: TimeFinder, + private val themeFinder: ThemeFinder, + private val tsidFactory: TsidFactory, +) { + fun create( + date: LocalDate, + timeId: Long, + themeId: Long, + memberId: Long, + status: ReservationStatus, + requesterId: Long + ): ReservationEntity { + log.debug { + "[ReservationWriter.create] 시작: " + + "date=${date}, timeId=${timeId}, themeId=${themeId}, memberId=${memberId}, status=${status}" + } + val time = timeFinder.findById(timeId).also { + reservationValidator.validateIsPast(date, it.startAt) + } + val theme = themeFinder.findById(themeId) + + val member = memberFinder.findById(memberId).also { + if (status == ReservationStatus.WAITING) { + reservationValidator.validateMemberAlreadyReserve(themeId, timeId, date, it.id!!) + } else { + reservationValidator.validateIsAlreadyExists(date, time, theme) + } + + if (memberId != requesterId) { + val requester = memberFinder.findById(requesterId) + reservationValidator.validateCreateAuthority(requester) + } + } + + val reservation = ReservationEntity( + _id = tsidFactory.next(), + date = date, + time = time, + theme = theme, + member = member, + status = status + ) + + return reservationRepository.save(reservation) + .also { log.debug { "[ReservationWriter.create] 완료: reservationId=${it.id}, status=${it.status}" } } + } + + fun deleteWaiting(reservation: ReservationEntity, requesterId: Long) { + log.debug { "[ReservationWriter.deleteWaiting] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" } + + reservationValidator.validateIsWaiting(reservation) + + delete(reservation, requesterId) + .also { log.debug { "[ReservationWriter.deleteWaiting] 완료: reservationId=${reservation.id}, status=${reservation.status}" } } + } + + fun deleteConfirmed(reservation: ReservationEntity, requesterId: Long) { + log.debug { "[ReservationWriter.deleteConfirmed] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" } + + delete(reservation, requesterId) + .also { log.debug { "[ReservationWriter.deleteConfirmed] 완료: reservationId=${reservation.id}, status=${reservation.status}" } } + } + + private fun delete(reservation: ReservationEntity, requesterId: Long) { + memberFinder.findById(requesterId) + .also { reservationValidator.validateDeleteAuthority(reservation, requester = it) } + + reservationRepository.delete(reservation) + } + + fun confirm(reservationId: Long) { + log.debug { "[ReservationWriter.confirm] 대기 여부 확인 시작: reservationId=$reservationId" } + + reservationValidator.validateAlreadyConfirmed(reservationId) + + reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) + + log.debug { "[ReservationWriter.confirm] 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" } + } +} diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt index e3c92f2f..3f8afa87 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt @@ -48,5 +48,10 @@ class ReservationEntity( enum class ReservationStatus { CONFIRMED, CONFIRMED_PAYMENT_REQUIRED, - WAITING + WAITING, + ; + + companion object { + fun confirmedStatus(): Array = arrayOf(CONFIRMED, CONFIRMED_PAYMENT_REQUIRED) + } } diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index e9f6c053..b3b0c607 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -1,17 +1,20 @@ package roomescape.reservation.infrastructure.persistence +import org.springframework.data.jpa.domain.Specification import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import roomescape.reservation.web.MyReservationRetrieveResponse +import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalDate interface ReservationRepository : JpaRepository, JpaSpecificationExecutor { fun findAllByTime(time: TimeEntity): List + fun existsByTime(time: TimeEntity): Boolean fun findByDateAndThemeId(date: LocalDate, themeId: Long): List @@ -20,11 +23,11 @@ interface ReservationRepository """ UPDATE ReservationEntity r SET r.status = :status - WHERE r.id = :id + WHERE r._id = :_id """ ) fun updateStatusByReservationId( - @Param(value = "id") reservationId: Long, + @Param(value = "_id") reservationId: Long, @Param(value = "status") statusForChange: ReservationStatus ): Int @@ -33,28 +36,28 @@ interface ReservationRepository SELECT EXISTS ( SELECT 1 FROM ReservationEntity r2 - WHERE r2.id = :id + WHERE r2._id = :_id AND EXISTS ( SELECT 1 FROM ReservationEntity r - WHERE r.theme.id = r2.theme.id - AND r.time.id = r2.time.id + WHERE r.theme._id = r2.theme._id + AND r.time._id = r2.time._id AND r.date = r2.date AND r.status != 'WAITING' ) ) """ ) - fun isExistConfirmedReservation(@Param("id") reservationId: Long): Boolean + fun isExistConfirmedReservation(@Param("_id") reservationId: Long): Boolean @Query( """ SELECT new roomescape.reservation.web.MyReservationRetrieveResponse( - r.id, + r._id, t.name, r.date, r.time.startAt, r.status, - (SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.time = r.time AND r2.id < r.id), + (SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.time = r.time AND r2._id < r._id), p.paymentKey, p.totalAmount ) @@ -62,8 +65,9 @@ interface ReservationRepository JOIN r.theme t LEFT JOIN PaymentEntity p ON p.reservation = r - WHERE r.member.id = :memberId + WHERE r.member._id = :memberId """ ) fun findAllByMemberId(memberId: Long): List + fun findAllByDateAndTheme(date: LocalDate, theme: ThemeEntity): List } diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt index fb6d01e8..f760a897 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt @@ -33,6 +33,10 @@ class ReservationSearchSpecification( } }) + fun status(vararg statuses: ReservationStatus) = andIfNotNull { root, _, cb -> + root.get("status").`in`(statuses.toList()) + } + fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb -> cb.or( cb.equal( diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt index fcd0565e..431a57be 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt @@ -10,7 +10,8 @@ import roomescape.payment.infrastructure.client.PaymentApproveRequest import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.client.TossPaymentClient import roomescape.payment.web.PaymentCancelRequest -import roomescape.reservation.business.ReservationService +import roomescape.reservation.business.ReservationWriteService +import roomescape.reservation.business.ReservationFindService import roomescape.reservation.business.ReservationWithPaymentService import roomescape.reservation.docs.ReservationAPI import java.net.URI @@ -19,12 +20,13 @@ import java.time.LocalDate @RestController class ReservationController( private val reservationWithPaymentService: ReservationWithPaymentService, - private val reservationService: ReservationService, + private val reservationFindService: ReservationFindService, + private val reservationWriteService: ReservationWriteService, private val paymentClient: TossPaymentClient ) : ReservationAPI { @GetMapping("/reservations") override fun findReservations(): ResponseEntity> { - val response: ReservationRetrieveListResponse = reservationService.findReservations() + val response: ReservationRetrieveListResponse = reservationFindService.findReservations() return ResponseEntity.ok(CommonApiResponse(response)) } @@ -33,7 +35,7 @@ class ReservationController( override fun findReservationsByMemberId( @MemberId @Parameter(hidden = true) memberId: Long ): ResponseEntity> { - val response: MyReservationRetrieveListResponse = reservationService.findReservationsByMemberId(memberId) + val response: MyReservationRetrieveListResponse = reservationFindService.findReservationsByMemberId(memberId) return ResponseEntity.ok(CommonApiResponse(response)) } @@ -45,7 +47,7 @@ class ReservationController( @RequestParam(required = false) dateFrom: LocalDate?, @RequestParam(required = false) dateTo: LocalDate? ): ResponseEntity> { - val response: ReservationRetrieveListResponse = reservationService.searchReservations( + val response: ReservationRetrieveListResponse = reservationFindService.searchReservations( themeId, memberId, dateFrom, @@ -61,7 +63,7 @@ class ReservationController( @PathVariable("id") reservationId: Long ): ResponseEntity> { if (reservationWithPaymentService.isNotPaidReservation(reservationId)) { - reservationService.deleteReservation(reservationId, memberId) + reservationWriteService.deleteReservation(reservationId, memberId) return ResponseEntity.noContent().build() } @@ -79,19 +81,19 @@ class ReservationController( override fun createReservationWithPayment( @Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest, @MemberId @Parameter(hidden = true) memberId: Long - ): ResponseEntity> { + ): ResponseEntity> { val paymentRequest: PaymentApproveRequest = reservationCreateWithPaymentRequest.toPaymentApproveRequest() val paymentResponse: PaymentApproveResponse = paymentClient.confirm(paymentRequest) try { - val reservationRetrieveResponse: ReservationRetrieveResponse = + val response: ReservationCreateResponse = reservationWithPaymentService.createReservationAndPayment( reservationCreateWithPaymentRequest, paymentResponse, memberId ) - return ResponseEntity.created(URI.create("/reservations/${reservationRetrieveResponse.id}")) - .body(CommonApiResponse(reservationRetrieveResponse)) + return ResponseEntity.created(URI.create("/reservations/${response.id}")) + .body(CommonApiResponse(response)) } catch (e: Exception) { val cancelRequest = PaymentCancelRequest( paymentRequest.paymentKey, @@ -110,10 +112,11 @@ class ReservationController( @PostMapping("/reservations/admin") override fun createReservationByAdmin( - @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest - ): ResponseEntity> { - val response: ReservationRetrieveResponse = - reservationService.createReservationByAdmin(adminReservationRequest) + @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest, + @MemberId @Parameter(hidden = true) memberId: Long, + ): ResponseEntity> { + val response: ReservationCreateResponse = + reservationWriteService.createReservationByAdmin(adminReservationRequest, memberId) return ResponseEntity.created(URI.create("/reservations/${response.id}")) .body(CommonApiResponse(response)) @@ -121,7 +124,7 @@ class ReservationController( @GetMapping("/reservations/waiting") override fun findAllWaiting(): ResponseEntity> { - val response: ReservationRetrieveListResponse = reservationService.findAllWaiting() + val response: ReservationRetrieveListResponse = reservationFindService.findAllWaiting() return ResponseEntity.ok(CommonApiResponse(response)) } @@ -130,8 +133,8 @@ class ReservationController( override fun createWaiting( @Valid @RequestBody waitingCreateRequest: WaitingCreateRequest, @MemberId @Parameter(hidden = true) memberId: Long, - ): ResponseEntity> { - val response: ReservationRetrieveResponse = reservationService.createWaiting( + ): ResponseEntity> { + val response: ReservationCreateResponse = reservationWriteService.createWaiting( waitingCreateRequest, memberId ) @@ -145,7 +148,7 @@ class ReservationController( @MemberId @Parameter(hidden = true) memberId: Long, @PathVariable("id") reservationId: Long ): ResponseEntity> { - reservationService.deleteWaiting(reservationId, memberId) + reservationWriteService.deleteWaiting(reservationId, memberId) return ResponseEntity.noContent().build() } @@ -155,7 +158,7 @@ class ReservationController( @MemberId @Parameter(hidden = true) memberId: Long, @PathVariable("id") reservationId: Long ): ResponseEntity> { - reservationService.confirmWaiting(reservationId, memberId) + reservationWriteService.confirmWaiting(reservationId, memberId) return ResponseEntity.ok().build() } @@ -165,7 +168,7 @@ class ReservationController( @MemberId @Parameter(hidden = true) memberId: Long, @PathVariable("id") reservationId: Long ): ResponseEntity> { - reservationService.rejectWaiting(reservationId, memberId) + reservationWriteService.deleteWaiting(reservationId, memberId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt b/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt index 7b733dac..2318c9c6 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt @@ -7,12 +7,37 @@ import roomescape.member.web.toRetrieveResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.theme.web.ThemeRetrieveResponse -import roomescape.theme.web.toResponse +import roomescape.theme.web.toRetrieveResponse import roomescape.time.web.TimeCreateResponse import roomescape.time.web.toCreateResponse import java.time.LocalDate import java.time.LocalTime +data class ReservationCreateResponse( + val id: Long, + val date: LocalDate, + + @JsonProperty("member") + val member: MemberRetrieveResponse, + + @JsonProperty("time") + val time: TimeCreateResponse, + + @JsonProperty("theme") + val theme: ThemeRetrieveResponse, + + val status: ReservationStatus +) + +fun ReservationEntity.toCreateResponse() = ReservationCreateResponse( + id = this.id!!, + date = this.date, + member = this.member.toRetrieveResponse(), + time = this.time.toCreateResponse(), + theme = this.theme.toRetrieveResponse(), + status = this.status +) + data class MyReservationRetrieveResponse( val id: Long, val themeName: String, @@ -32,17 +57,19 @@ data class MyReservationRetrieveListResponse( val reservations: List ) +fun List.toRetrieveListResponse() = MyReservationRetrieveListResponse(this) + data class ReservationRetrieveResponse( val id: Long, val date: LocalDate, - @field:JsonProperty("member") + @JsonProperty("member") val member: MemberRetrieveResponse, - @field:JsonProperty("time") + @JsonProperty("time") val time: TimeCreateResponse, - @field:JsonProperty("theme") + @JsonProperty("theme") val theme: ThemeRetrieveResponse, val status: ReservationStatus @@ -53,10 +80,14 @@ fun ReservationEntity.toRetrieveResponse(): ReservationRetrieveResponse = Reserv date = this.date, member = this.member.toRetrieveResponse(), time = this.time.toCreateResponse(), - theme = this.theme.toResponse(), + theme = this.theme.toRetrieveResponse(), status = this.status ) data class ReservationRetrieveListResponse( val reservations: List ) + +fun List.toRetrieveListResponse()= ReservationRetrieveListResponse( + this.map { it.toRetrieveResponse() } +) diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 665fe7a2..741195c3 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -1,92 +1,67 @@ package roomescape.theme.business -import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.config.next -import roomescape.theme.exception.ThemeErrorCode -import roomescape.theme.exception.ThemeException +import roomescape.theme.implement.ThemeFinder +import roomescape.theme.implement.ThemeWriter import roomescape.theme.infrastructure.persistence.ThemeEntity -import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.ThemeCreateRequest -import roomescape.theme.web.ThemeRetrieveListResponse -import roomescape.theme.web.ThemeRetrieveResponse -import roomescape.theme.web.toResponse +import roomescape.theme.web.* import java.time.LocalDate private val log = KotlinLogging.logger {} @Service class ThemeService( - private val tsidFactory: TsidFactory, - private val themeRepository: ThemeRepository, + private val themeFinder: ThemeFinder, + private val themeWriter: ThemeWriter, ) { @Transactional(readOnly = true) fun findById(id: Long): ThemeEntity { - log.debug { "[ThemeService.findById] 테마 조회 시작: themeId=$id" } + log.debug { "[ThemeService.findById] 시작: themeId=$id" } - return themeRepository.findByIdOrNull(id) - ?.also { log.info { "[ThemeService.findById] 테마 조회 완료: themeId=$id" } } - ?: run { - log.warn { "[ThemeService.findById] 테마 조회 실패: themeId=$id" } - throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) - } + return themeFinder.findById(id) + .also { log.info { "[ThemeService.findById] 완료: themeId=$id, name=${it.name}" } } } @Transactional(readOnly = true) fun findThemes(): ThemeRetrieveListResponse { - log.debug { "[ThemeService.findThemes] 모든 테마 조회 시작" } + log.debug { "[ThemeService.findThemes] 시작" } - return themeRepository.findAll() - .also { log.info { "[ThemeService.findThemes] ${it.size}개의 테마 조회 완료" } } - .toResponse() + return themeFinder.findAll() + .toRetrieveListResponse() + .also { log.info { "[ThemeService.findThemes] 완료. ${it.themes.size}개 반환" } } } @Transactional(readOnly = true) fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse { - log.debug { "[ThemeService.findMostReservedThemes] 인기 테마 조회 시작: count=$count" } + log.debug { "[ThemeService.findMostReservedThemes] 시작: count=$count" } val today = LocalDate.now() - val startDate = today.minusDays(7) - val endDate = today.minusDays(1) + val startFrom = today.minusDays(7) + val endAt = today.minusDays(1) - return themeRepository.findPopularThemes(startDate, endDate, count) - .also { log.info { "[ThemeService.findMostReservedThemes] ${it.size} 개의 인기 테마 조회 완료" } } - .toResponse() + return themeFinder.findMostReservedThemes(count, startFrom, endAt) + .toRetrieveListResponse() + .also { log.info { "[ThemeService.findMostReservedThemes] ${it.themes.size}개 반환" } } } @Transactional - fun createTheme(request: ThemeCreateRequest): ThemeRetrieveResponse { - log.debug { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" } + fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse { + log.debug { "[ThemeService.createTheme] 시작: name=${request.name}" } - if (themeRepository.existsByName(request.name)) { - log.info { "[ThemeService.createTheme] 테마 생성 실패(이름 중복): name=${request.name}" } - throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) - } - - val theme = ThemeEntity( - _id = tsidFactory.next(), - name = request.name, - description = request.description, - thumbnail = request.thumbnail - ) - return themeRepository.save(theme) - .also { log.info { "[ThemeService.createTheme] 테마 생성 완료: themeId=${it.id}" } } - .toResponse() + return themeWriter.create(request.name, request.description, request.thumbnail) + .toCreateResponse() + .also { log.info { "[ThemeService.createTheme] 테마 생성 완료: name=${it.name} themeId=${it.id}" } } } @Transactional fun deleteTheme(id: Long) { - log.debug { "[ThemeService.deleteTheme] 테마 삭제 시작: themeId=$id" } + log.debug { "[ThemeService.deleteTheme] 시작: themeId=$id" } - if (themeRepository.isReservedTheme(id)) { - log.info { "[ThemeService.deleteTheme] 테마 삭제 실패(예약이 있는 테마): themeId=$id" } - throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED) - } + val theme: ThemeEntity = themeFinder.findById(id) - themeRepository.deleteById(id) - .also { log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: themeId=$id" } } + themeWriter.delete(theme) + .also { log.info { "[ThemeService.deleteTheme] 완료: themeId=$id, name=${theme.name}" } } } } diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt b/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt index 4f3f50d3..0a9db7dc 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt @@ -14,6 +14,7 @@ import roomescape.auth.web.support.Admin import roomescape.auth.web.support.LoginRequired import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.web.ThemeCreateRequest +import roomescape.theme.web.ThemeCreateResponse import roomescape.theme.web.ThemeRetrieveListResponse import roomescape.theme.web.ThemeRetrieveResponse @@ -38,7 +39,7 @@ interface ThemeAPI { ) fun createTheme( @Valid @RequestBody request: ThemeCreateRequest, - ): ResponseEntity> + ): ResponseEntity> @Admin @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) diff --git a/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt b/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt index 2f05632c..9e8450c1 100644 --- a/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt +++ b/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt @@ -10,5 +10,6 @@ enum class ThemeErrorCode( ) : ErrorCode { THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "TH001", "테마를 찾을 수 없어요."), THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."), - THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요.") + THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요."), + INVALID_REQUEST_VALUE(HttpStatus.BAD_REQUEST, "TH004", "입력 값이 잘못되었어요."), } diff --git a/src/main/kotlin/roomescape/theme/implement/ThemeFinder.kt b/src/main/kotlin/roomescape/theme/implement/ThemeFinder.kt new file mode 100644 index 00000000..42a86062 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/implement/ThemeFinder.kt @@ -0,0 +1,47 @@ +package roomescape.theme.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.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.theme.infrastructure.persistence.ThemeRepository +import java.time.LocalDate + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ThemeFinder( + private val themeRepository: ThemeRepository +) { + fun findAll(): List { + log.debug { "[ThemeFinder.findAll] 시작" } + + return themeRepository.findAll() + .also { log.debug { "[TimeFinder.findAll] ${it.size}개 테마 조회 완료" } } + } + + fun findById(id: Long): ThemeEntity { + log.debug { "[ThemeFinder.findById] 조회 시작: memberId=$id" } + + return themeRepository.findByIdOrNull(id) + ?.also { log.debug { "[ThemeFinder.findById] 조회 완료: id=$id, name=${it.name}" } } + ?: run { + log.warn { "[ThemeFinder.findById] 조회 실패: id=$id" } + throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + } + } + + fun findMostReservedThemes( + count: Int, + startFrom: LocalDate, + endAt: LocalDate + ): List { + log.debug { "[ThemeFinder.findMostReservedThemes] 시작. count=$count, startFrom=$startFrom, endAt=$endAt" } + + return themeRepository.findPopularThemes(startFrom, endAt, count) + .also { log.debug { "[ThemeFinder.findMostReservedThemes] ${it.size} / ${count}개 테마 조회 완료" } } + } +} diff --git a/src/main/kotlin/roomescape/theme/implement/ThemeValidator.kt b/src/main/kotlin/roomescape/theme/implement/ThemeValidator.kt new file mode 100644 index 00000000..6887d894 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/implement/ThemeValidator.kt @@ -0,0 +1,43 @@ +package roomescape.theme.implement + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.theme.infrastructure.persistence.ThemeRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ThemeValidator( + private val themeRepository: ThemeRepository +) { + fun validateNameAlreadyExists(name: String) { + log.debug { "[ThemeValidator.validateNameAlreadyExists] 시작: name=$name" } + + if (themeRepository.existsByName(name)) { + log.info { "[ThemeService.createTheme] 이름 중복: name=${name}" } + throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) + } + + log.debug { "[ThemeValidator.validateNameAlreadyExists] 완료: name=$name" } + } + + fun validateIsReserved(theme: ThemeEntity) { + val themeId: Long = theme.id ?: run { + log.warn { "[ThemeValidator.validateIsReserved] ID를 찾을 수 없음: name:${theme.name}" } + throw ThemeException(ThemeErrorCode.INVALID_REQUEST_VALUE) + } + + log.debug { "[ThemeValidator.validateIsReserved] 시작: themeId=${themeId}" } + + if (themeRepository.isReservedTheme(themeId)) { + log.info { "[ThemeService.deleteTheme] 예약이 있는 테마: themeId=$themeId" } + throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED) + } + + log.debug { "[ThemeValidator.validateIsReserved] 완료: themeId=$themeId" } + } +} diff --git a/src/main/kotlin/roomescape/theme/implement/ThemeWriter.kt b/src/main/kotlin/roomescape/theme/implement/ThemeWriter.kt new file mode 100644 index 00000000..3f07b335 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/implement/ThemeWriter.kt @@ -0,0 +1,41 @@ +package roomescape.theme.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.theme.infrastructure.persistence.ThemeEntity +import roomescape.theme.infrastructure.persistence.ThemeRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ThemeWriter( + private val themeValidator: ThemeValidator, + private val themeRepository: ThemeRepository, + private val tsidFactory: TsidFactory +) { + fun create(name: String, description: String, thumbnail: String): ThemeEntity { + log.debug { "[ThemeWriter.create] 시작: name=$name" } + themeValidator.validateNameAlreadyExists(name) + + val theme = ThemeEntity( + _id = tsidFactory.next(), + name = name, + description = description, + thumbnail = thumbnail + ) + + return themeRepository.save(theme) + .also { log.debug { "[ThemeWriter.create] 완료: name=$name, id=${it.id}" } } + } + + fun delete(theme: ThemeEntity) { + log.debug { "[ThemeWriter.delete] 시작: id=${theme.id}" } + themeValidator.validateIsReserved(theme) + + themeRepository.delete(theme) + .also { log.debug { "[ThemeWriter.delete] 완료: id=${theme.id}, name=${theme.name}" } } + } +} diff --git a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt index fbb83c9d..c7129a93 100644 --- a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt +++ b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt @@ -6,27 +6,25 @@ import java.time.LocalDate interface ThemeRepository : JpaRepository { - @Query( - value = """ + @Query(value = """ SELECT t FROM ThemeEntity t - RIGHT JOIN ReservationEntity r ON t.id = r.theme.id - WHERE r.date BETWEEN :startDate AND :endDate - GROUP BY r.theme.id - ORDER BY COUNT(r.theme.id) DESC, t.id ASC - LIMIT :limit + RIGHT JOIN ReservationEntity r ON t._id = r.theme._id + WHERE r.date BETWEEN :startFrom AND :endAt + GROUP BY r.theme._id + ORDER BY COUNT(r.theme._id) DESC, t._id ASC + LIMIT :count """ ) - fun findPopularThemes(startDate: LocalDate, endDate: LocalDate, limit: Int): List + fun findPopularThemes(startFrom: LocalDate, endAt: LocalDate, count: Int): List fun existsByName(name: String): Boolean - @Query( - value = """ + @Query(value = """ SELECT EXISTS( SELECT 1 FROM ReservationEntity r - WHERE r.theme.id = :id + WHERE r.theme._id = :id ) """ ) diff --git a/src/main/kotlin/roomescape/theme/web/ThemeController.kt b/src/main/kotlin/roomescape/theme/web/ThemeController.kt index 6931b37c..3b12fb3c 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeController.kt @@ -33,8 +33,8 @@ class ThemeController( @PostMapping("/themes") override fun createTheme( @RequestBody @Valid request: ThemeCreateRequest - ): ResponseEntity> { - val themeResponse: ThemeRetrieveResponse = themeService.createTheme(request) + ): ResponseEntity> { + val themeResponse: ThemeCreateResponse = themeService.createTheme(request) return ResponseEntity.created(URI.create("/themes/${themeResponse.id}")) .body(CommonApiResponse(themeResponse)) diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt b/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt index 4db87e77..32b14485 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt @@ -21,6 +21,21 @@ data class ThemeCreateRequest( val thumbnail: String ) +data class ThemeCreateResponse( + val id: Long, + val name: String, + val description: String, + @Schema(description = "썸네일 이미지 주소(URL).") + val thumbnail: String +) + +fun ThemeEntity.toCreateResponse(): ThemeCreateResponse = ThemeCreateResponse( + id = this.id!!, + name = this.name, + description = this.description, + thumbnail = this.thumbnail +) + data class ThemeRetrieveResponse( val id: Long, val name: String, @@ -29,7 +44,7 @@ data class ThemeRetrieveResponse( val thumbnail: String ) -fun ThemeEntity.toResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse( +fun ThemeEntity.toRetrieveResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse( id = this.id!!, name = this.name, description = this.description, @@ -40,6 +55,6 @@ data class ThemeRetrieveListResponse( val themes: List ) -fun List.toResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse( - themes = this.map { it.toResponse() } +fun List.toRetrieveListResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse( + themes = this.map { it.toRetrieveResponse() } ) diff --git a/src/main/kotlin/roomescape/time/business/TimeService.kt b/src/main/kotlin/roomescape/time/business/TimeService.kt index a49b8561..42af422d 100644 --- a/src/main/kotlin/roomescape/time/business/TimeService.kt +++ b/src/main/kotlin/roomescape/time/business/TimeService.kt @@ -1,17 +1,11 @@ package roomescape.time.business -import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.config.next -import roomescape.reservation.infrastructure.persistence.ReservationEntity -import roomescape.reservation.infrastructure.persistence.ReservationRepository -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.infrastructure.persistence.TimeRepository import roomescape.time.web.* import java.time.LocalDate import java.time.LocalTime @@ -20,87 +14,61 @@ private val log = KotlinLogging.logger {} @Service class TimeService( - private val tsidFactory: TsidFactory, - private val timeRepository: TimeRepository, - private val reservationRepository: ReservationRepository, + private val timeFinder: TimeFinder, + private val timeWriter: TimeWriter, ) { @Transactional(readOnly = true) fun findById(id: Long): TimeEntity { - log.debug { "[TimeService.findById] 시간 조회 시작: timeId=$id" } - return timeRepository.findByIdOrNull(id) - ?.also { log.info { "[TimeService.findById] 시간 조회 완료: timeId=$id" } } - ?: run { - log.warn { "[TimeService.findById] 시간 조회 실패: timeId=$id" } - throw TimeException(TimeErrorCode.TIME_NOT_FOUND) - } + 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 timeRepository.findAll() - .also { log.info { "[TimeService.findTimes] ${it.size}개의 시간 조회 완료" } } + log.debug { "[TimeService.findTimes] 시작" } + + return timeFinder.findAll() .toResponse() - } - - @Transactional - fun createTime(request: TimeCreateRequest): TimeCreateResponse { - log.debug { "[TimeService.createTime] 시간 생성 시작: startAt=${request.startAt}" } - - val startAt: LocalTime = request.startAt - if (timeRepository.existsByStartAt(startAt)) { - log.info { "[TimeService.createTime] 시간 생성 실패(시간 중복): startAt=$startAt" } - throw TimeException(TimeErrorCode.TIME_DUPLICATED) - } - - val time = TimeEntity( - _id = tsidFactory.next(), - startAt = request.startAt - ) - return timeRepository.save(time) - .also { log.info { "[TimeService.createTime] 시간 생성 완료: timeId=${it.id}" } } - .toCreateResponse() - } - - @Transactional - fun deleteTime(id: Long) { - log.debug { "[TimeService.deleteTime] 시간 삭제 시작: timeId=$id" } - - val time: TimeEntity = findById(id) - - log.debug { "[TimeService.deleteTime] 시간이 ${time.startAt}인 모든 예약 조회 시작" } - val reservations: List = reservationRepository.findAllByTime(time) - log.debug { "[TimeService.deleteTime] 시간이 ${time.startAt}인 모든 ${reservations.size} 개의 예약 조회 완료" } - - if (reservations.isNotEmpty()) { - log.info { "[TimeService.deleteTime] 시간 삭제 실패(예약이 있는 시간): timeId=$id" } - throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED) - } - - timeRepository.delete(time) - .also { log.info { "[TimeService.deleteTime] 시간 삭제 완료: timeId=$id" } } + .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" } + log.debug { "[TimeService.findTimesWithAvailability] 시작: date=$date, themeId=$themeId" } - log.debug { "[TimeService.findTimesWithAvailability] 모든 시간 조회 " } - val allTimes = timeRepository.findAll() - log.debug { "[TimeService.findTimesWithAvailability] ${allTimes.size}개의 시간 조회 완료" } + val times: List = + timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId) + .map { + TimeWithAvailabilityResponse( + id = it.timeId, + startAt = it.startAt, + isAvailable = it.isReservable + ) + } - log.debug { "[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId 인 모든 예약 조회 시작" } - val reservations: List = reservationRepository.findByDateAndThemeId(date, themeId) - log.debug { "[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId 인 ${reservations.size} 개의 예약 조회 완료" } + 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 TimeWithAvailabilityListResponse(allTimes.map { time -> - val isAvailable: Boolean = reservations.none { reservation -> reservation.time.id == time.id } - TimeWithAvailabilityResponse(time.id!!, time.startAt, isAvailable) - }).also { - log.info { - "[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId 에 대한 예약 가능 여부가 담긴 모든 시간 조회 완료" - } - } + 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}" } } } } diff --git a/src/main/kotlin/roomescape/time/business/domain/TimeWithAvailability.kt b/src/main/kotlin/roomescape/time/business/domain/TimeWithAvailability.kt new file mode 100644 index 00000000..c53000d5 --- /dev/null +++ b/src/main/kotlin/roomescape/time/business/domain/TimeWithAvailability.kt @@ -0,0 +1,12 @@ +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 +) diff --git a/src/main/kotlin/roomescape/time/implement/TimeFinder.kt b/src/main/kotlin/roomescape/time/implement/TimeFinder.kt new file mode 100644 index 00000000..609a0fa3 --- /dev/null +++ b/src/main/kotlin/roomescape/time/implement/TimeFinder.kt @@ -0,0 +1,60 @@ +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 { + 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 { + log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] 조회 시작: date:$date, themeId=$themeId" } + + val theme = themeFinder.findById(themeId) + val reservations: List = reservationFinder.findAllByDateAndTheme(date, theme) + val allTimes: List = findAll() + + return allTimes.map { time -> + val isReservable: Boolean = reservations.any { reservation -> time.id == reservation.id } + TimeWithAvailability(time.id!!, time.startAt, date, themeId, isReservable) + }.also { + log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] ${it.size}개 조회 완료: date:$date, themeId=$themeId" } + } + } +} diff --git a/src/main/kotlin/roomescape/time/implement/TimeValidator.kt b/src/main/kotlin/roomescape/time/implement/TimeValidator.kt new file mode 100644 index 00000000..ef0cee93 --- /dev/null +++ b/src/main/kotlin/roomescape/time/implement/TimeValidator.kt @@ -0,0 +1,41 @@ +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}" } + } +} diff --git a/src/main/kotlin/roomescape/time/implement/TimeWriter.kt b/src/main/kotlin/roomescape/time/implement/TimeWriter.kt new file mode 100644 index 00000000..f1013697 --- /dev/null +++ b/src/main/kotlin/roomescape/time/implement/TimeWriter.kt @@ -0,0 +1,37 @@ +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}" } } + } +} diff --git a/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt b/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt index aa570047..8609ba3e 100644 --- a/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt +++ b/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt @@ -6,23 +6,21 @@ import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk -import org.springframework.data.repository.findByIdOrNull import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtHandler -import roomescape.member.business.MemberService +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.implement.MemberFinder import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.util.JwtFixture import roomescape.util.MemberFixture -import roomescape.util.TsidFactory class AuthServiceTest : BehaviorSpec({ - val memberRepository: MemberRepository = mockk() - val memberService = MemberService(TsidFactory, memberRepository) + val memberFinder: MemberFinder = mockk() val jwtHandler: JwtHandler = JwtFixture.create() - val authService = AuthService(memberService, jwtHandler) + val authService = AuthService(memberFinder, jwtHandler) val user: MemberEntity = MemberFixture.user() Given("로그인 요청을 받으면") { @@ -31,7 +29,7 @@ class AuthServiceTest : BehaviorSpec({ Then("회원이 있다면 JWT 토큰을 생성한 뒤 반환한다.") { every { - memberRepository.findByEmailAndPassword(request.email, request.password) + memberFinder.findByEmailAndPassword(request.email, request.password) } returns user val accessToken: String = authService.login(request).accessToken @@ -42,8 +40,8 @@ class AuthServiceTest : BehaviorSpec({ Then("회원이 없다면 예외를 던진다.") { every { - memberRepository.findByEmailAndPassword(request.email, request.password) - } returns null + memberFinder.findByEmailAndPassword(request.email, request.password) + } throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND) val exception = shouldThrow { authService.login(request) @@ -59,7 +57,7 @@ class AuthServiceTest : BehaviorSpec({ val userId: Long = user.id!! Then("회원이 있다면 회원의 이름을 반환한다.") { - every { memberRepository.findByIdOrNull(userId) } returns user + every { memberFinder.findById(userId) } returns user val response = authService.checkLogin(userId) @@ -69,7 +67,9 @@ class AuthServiceTest : BehaviorSpec({ } Then("회원이 없다면 예외를 던진다.") { - every { memberRepository.findByIdOrNull(userId) } returns null + every { + memberFinder.findById(userId) + } throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND) val exception = shouldThrow { authService.checkLogin(userId) diff --git a/src/test/kotlin/roomescape/auth/infrastructure/jwt/JwtHandlerTest.kt b/src/test/kotlin/roomescape/auth/infrastructure/jwt/JwtHandlerTest.kt index 64ca2bd3..9d9c91bc 100644 --- a/src/test/kotlin/roomescape/auth/infrastructure/jwt/JwtHandlerTest.kt +++ b/src/test/kotlin/roomescape/auth/infrastructure/jwt/JwtHandlerTest.kt @@ -25,14 +25,12 @@ class JwtHandlerTest : FunSpec({ } test("만료된 토큰이면 예외를 던진다.") { - // given val expirationTime = 0L val shortExpirationTimeJwtHandler: JwtHandler = JwtFixture.create(expirationTime = expirationTime) val token = shortExpirationTimeJwtHandler.createToken(memberId) Thread.sleep(expirationTime) // 만료 시간 이후로 대기 - // when & then shouldThrow { shortExpirationTimeJwtHandler.getMemberIdFromToken(token) }.errorCode shouldBe AuthErrorCode.EXPIRED_TOKEN diff --git a/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt b/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt index 0e895d9d..aed2ee55 100644 --- a/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt +++ b/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt @@ -1,24 +1,24 @@ package roomescape.auth.web -import com.ninjasquad.springmockk.SpykBean +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs import io.mockk.every +import io.mockk.just import org.hamcrest.Matchers.equalTo import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.data.repository.findByIdOrNull import org.springframework.test.web.servlet.MockMvc import roomescape.auth.business.AuthService import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException import roomescape.common.exception.CommonErrorCode import roomescape.common.exception.ErrorCode import roomescape.util.MemberFixture import roomescape.util.RoomescapeApiTest @WebMvcTest(controllers = [AuthController::class]) -class AuthControllerTest( - val mockMvc: MockMvc -) : RoomescapeApiTest() { +class AuthControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() { - @SpykBean + @MockkBean private lateinit var authService: AuthService val userRequest: LoginRequest = MemberFixture.userLoginRequest() @@ -31,12 +31,8 @@ class AuthControllerTest( val expectedToken = "expectedToken" every { - memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password) - } returns user - - every { - jwtHandler.createToken(user.id!!) - } returns expectedToken + authService.login(userRequest) + } returns LoginResponse(expectedToken) Then("토큰을 반환한다.") { runPostTest( @@ -51,12 +47,13 @@ class AuthControllerTest( } When("회원을 찾지 못하면") { + val expectedError = AuthErrorCode.LOGIN_FAILED + every { - memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password) - } returns null + authService.login(userRequest) + } throws AuthException(expectedError) Then("에러 응답을 받는다.") { - val expectedError = AuthErrorCode.LOGIN_FAILED runPostTest( mockMvc = mockMvc, endpoint = endpoint, @@ -67,6 +64,7 @@ class AuthControllerTest( } } } + When("입력 값이 잘못되면") { val expectedErrorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE @@ -96,6 +94,10 @@ class AuthControllerTest( loginAsUser() Then("회원의 이름과 권한을 응답한다") { + every { + authService.checkLogin(user.id!!) + } returns LoginCheckResponse(user.name, user.role.name) + runGetTest( mockMvc = mockMvc, endpoint = endpoint, @@ -109,12 +111,12 @@ class AuthControllerTest( When("토큰은 있지만 회원을 찾을 수 없으면") { val invalidMemberId: Long = -1L + val expectedError = AuthErrorCode.MEMBER_NOT_FOUND every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId - every { memberRepository.findByIdOrNull(invalidMemberId) } returns null + every { authService.checkLogin(invalidMemberId) } throws AuthException(expectedError) Then("에러 응답을 받는다.") { - val expectedError = AuthErrorCode.MEMBER_NOT_FOUND runGetTest( mockMvc = mockMvc, endpoint = endpoint, @@ -129,13 +131,11 @@ class AuthControllerTest( val endpoint = "/logout" When("토큰으로 memberId 조회가 가능하면") { - every { - jwtHandler.getMemberIdFromToken(any()) - } returns 1L + loginAsUser() every { - memberRepository.findByIdOrNull(1L) - } returns MemberFixture.create(id = 1L) + authService.logout(user.id!!) + } just Runs Then("정상 응답한다.") { runPostTest( diff --git a/src/test/kotlin/roomescape/member/business/MemberServiceTest.kt b/src/test/kotlin/roomescape/member/business/MemberServiceTest.kt new file mode 100644 index 00000000..db82fc0b --- /dev/null +++ b/src/test/kotlin/roomescape/member/business/MemberServiceTest.kt @@ -0,0 +1,100 @@ +package roomescape.member.business + +import io.kotest.assertions.assertSoftly +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.every +import io.mockk.mockk +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.implement.MemberFinder +import roomescape.member.implement.MemberWriter +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.member.infrastructure.persistence.Role +import roomescape.member.web.SignupRequest +import roomescape.util.MemberFixture + +class MemberServiceTest : FunSpec({ + val memberWriter: MemberWriter = mockk() + val memberFinder: MemberFinder = mockk() + + val memberService = MemberService(memberWriter, memberFinder) + + context("findMembers") { + test("정상 응답") { + val members: List = listOf( + MemberFixture.create(name = "user1"), + MemberFixture.create(name = "user2"), + ) + + every { memberFinder.findAll() } returns members + + val response = memberService.findMembers() + + // then + assertSoftly(response.members) { + it shouldHaveSize 2 + it.map { member -> member.name } shouldContainExactly listOf("user1", "user2") + } + } + } + + context("findById") { + val id = 1L + + test("정상 응답") { + every { + memberFinder.findById(id) + } returns MemberFixture.create(id = id) + + memberService.findById(id).id shouldBe id + } + + test("회원을 찾을 수 없으면 예외 응답") { + every { + memberFinder.findById(id) + } throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND) + + shouldThrow { + memberService.findById(id) + }.also { + it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND + } + } + } + + context("createMember") { + val request = SignupRequest(name = "new-user", email = "new@test.com", password = "password") + + test("정상 저장") { + val member = MemberFixture.create( + name = request.name, + account = request.email, + password = request.password + ) + + every { + memberWriter.create(request.name, request.email, request.password, Role.MEMBER) + } returns member + + val response = memberService.createMember(request) + + response.id shouldBe member.id + } + + test("중복된 이메일이 있으면 예외 응답") { + every { + memberWriter.create(request.name, request.email, request.password, Role.MEMBER) + } throws MemberException(MemberErrorCode.DUPLICATE_EMAIL) + + shouldThrow { + memberService.createMember(request) + }.also { + it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL + } + } + } +}) diff --git a/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt b/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt index 8eaef1c0..5f3a9dc1 100644 --- a/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt +++ b/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt @@ -1,57 +1,53 @@ package roomescape.member.controller +import com.ninjasquad.springmockk.MockkBean import io.kotest.assertions.assertSoftly import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.shouldBe import io.mockk.every -import io.mockk.mockk -import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.test.web.servlet.MockMvc import roomescape.auth.exception.AuthErrorCode +import roomescape.member.business.MemberService import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException import roomescape.member.infrastructure.persistence.Role -import roomescape.member.web.MemberController -import roomescape.member.web.MemberRetrieveListResponse -import roomescape.member.web.SignupRequest +import roomescape.member.web.* import roomescape.util.MemberFixture import roomescape.util.RoomescapeApiTest import kotlin.random.Random @WebMvcTest(controllers = [MemberController::class]) -class MemberControllerTest( - @Autowired private val mockMvc: MockMvc -) : RoomescapeApiTest() { +class MemberControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() { + @MockkBean + private lateinit var memberService: MemberService init { given("GET /members 요청을") { val endpoint = "/members" - - every { memberRepository.findAll() } returns listOf( + val response = listOf( MemberFixture.create(id = Random.nextLong(), name = "name1"), MemberFixture.create(id = Random.nextLong(), name = "name2"), MemberFixture.create(id = Random.nextLong(), name = "name3"), - ) + ).toRetrieveListResponse() + + every { memberService.findMembers() } returns response `when`("관리자가 보내면") { loginAsAdmin() then("성공한다.") { - val result: String = runGetTest( + val result: MemberRetrieveListResponse = runGetTest( mockMvc = mockMvc, endpoint = endpoint, ) { status { isOk() } - }.andReturn().response.contentAsString + }.andReturn().readValue(MemberRetrieveListResponse::class.java) - val response: MemberRetrieveListResponse = readValue( - responseJson = result, - valueType = MemberRetrieveListResponse::class.java - ) - assertSoftly(response.members) { - it.size shouldBe 3 - it.map { m -> m.name } shouldContainAll listOf("name1", "name2", "name3") + assertSoftly(result.members) { + it.size shouldBe response.members.size + it.map { m -> m.name } shouldContainAll response.members.map { m -> m.name } } } } @@ -96,18 +92,14 @@ class MemberControllerTest( ) `when`("같은 이메일이 없으면") { every { - memberRepository.findByEmail(request.email) - } returns null - - every { - memberRepository.save(any()) + memberService.createMember(request) } returns MemberFixture.create( id = 1, name = request.name, account = request.email, password = request.password, role = Role.MEMBER - ) + ).toSignupResponse() then("id과 이름을 담아 성공 응답") { runPostTest( @@ -123,13 +115,12 @@ class MemberControllerTest( } `when`("같은 이메일이 있으면") { + val expectedError = MemberErrorCode.DUPLICATE_EMAIL every { - memberRepository.findByEmail(request.email) - } returns mockk() + memberService.createMember(request) + } throws MemberException(expectedError) then("에러 응답") { - val expectedError = MemberErrorCode.DUPLICATE_EMAIL - runPostTest( mockMvc = mockMvc, endpoint = endpoint, @@ -138,7 +129,6 @@ class MemberControllerTest( status { isEqualTo(expectedError.httpStatus.value()) } jsonPath("$.code") { value(expectedError.errorCode) } } - } } } diff --git a/src/test/kotlin/roomescape/member/implement/MemberFinderTest.kt b/src/test/kotlin/roomescape/member/implement/MemberFinderTest.kt new file mode 100644 index 00000000..e1be29f7 --- /dev/null +++ b/src/test/kotlin/roomescape/member/implement/MemberFinderTest.kt @@ -0,0 +1,85 @@ +package roomescape.member.implement + +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.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberRepository + +class MemberFinderTest : FunSpec({ + + val memberRepository: MemberRepository = mockk() + val memberFinder = MemberFinder(memberRepository) + + context("findAll") { + test("모든 회원을 조회한다.") { + every { + memberRepository.findAll() + } returns listOf(mockk(), mockk(), mockk()) + + memberFinder.findAll() shouldHaveSize 3 + } + } + + context("findById") { + val memberId = 1L + test("동일한 ID인 회원을 찾아 응답한다.") { + every { + memberRepository.findByIdOrNull(memberId) + } returns mockk() + + memberFinder.findById(memberId) + + verify(exactly = 1) { + memberRepository.findByIdOrNull(memberId) + } + } + + test("동일한 ID인 회원이 없으면 실패한다.") { + every { + memberRepository.findByIdOrNull(memberId) + } returns null + + shouldThrow { + memberFinder.findById(memberId) + }.also { + it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND + } + } + } + + context("findByEmailAndPassword") { + val email = "email" + val password = "password" + + test("동일한 이메일과 비밀번호를 가진 회원을 찾아 응답한다.") { + every { + memberRepository.findByEmailAndPassword(email, password) + } returns mockk() + + memberFinder.findByEmailAndPassword(email, password) + + verify(exactly = 1) { + memberRepository.findByEmailAndPassword(email, password) + } + } + + test("동일한 이메일과 비밀번호를 가진 회원이 없으면 실패한다.") { + every { + memberRepository.findByEmailAndPassword(email, password) + } returns null + + shouldThrow { + memberFinder.findByEmailAndPassword(email, password) + }.also { + it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND + } + } + } +}) diff --git a/src/test/kotlin/roomescape/member/implement/MemberValidatorTest.kt b/src/test/kotlin/roomescape/member/implement/MemberValidatorTest.kt new file mode 100644 index 00000000..ade55e4b --- /dev/null +++ b/src/test/kotlin/roomescape/member/implement/MemberValidatorTest.kt @@ -0,0 +1,44 @@ +package roomescape.member.implement + +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberRepository + +class MemberValidatorTest : FunSpec({ + + val memberRepository: MemberRepository = mockk() + val memberValidator = MemberValidator(memberRepository) + + context("validateCanSignup") { + val email = "email@email.com" + + test("같은 이메일을 가진 회원이 있으면 예외를 던진다.") { + every { + memberRepository.existsByEmail(email) + } returns true + + shouldThrow { + memberValidator.validateCanSignup(email) + }.also { + it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL + } + } + + test("같은 이메일을 가진 회원이 없으면 종료한다.") { + every { + memberRepository.existsByEmail(email) + } returns false + + shouldNotThrow { + memberValidator.validateCanSignup(email) + } + } + } +}) diff --git a/src/test/kotlin/roomescape/member/implement/MemberWriterTest.kt b/src/test/kotlin/roomescape/member/implement/MemberWriterTest.kt new file mode 100644 index 00000000..16e09201 --- /dev/null +++ b/src/test/kotlin/roomescape/member/implement/MemberWriterTest.kt @@ -0,0 +1,65 @@ +package roomescape.member.implement + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.member.infrastructure.persistence.MemberRepository +import roomescape.member.infrastructure.persistence.Role +import roomescape.util.MemberFixture +import roomescape.util.TsidFactory + +class MemberWriterTest : FunSpec({ + val memberRepository: MemberRepository = mockk() + val memberValidator = MemberValidator(memberRepository) + val memberWriter = MemberWriter(TsidFactory, memberValidator, memberRepository) + + context("create") { + val name = "name" + val email = "email" + val password = "password" + val role = Role.MEMBER + + test("중복된 이메일이 있으면 실패한다.") { + every { + memberRepository.existsByEmail(any()) + } returns true + + shouldThrow { + memberWriter.create(name, email, password, role) + }.also { + it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL + } + } + + test("중복된 이메일이 없으면 저장한다.") { + every { + memberRepository.existsByEmail(any()) + } returns false + + every { + memberRepository.save(any()) + } returns MemberFixture.create( + name = name, + account = email, + password = password, + role = role + ) + + memberWriter.create(name, email, password, role) + + verify(exactly = 1) { + memberRepository.save(any()) + } + } + } +}) diff --git a/src/test/kotlin/roomescape/member/infrastructure/persistence/MemberRepositoryTest.kt b/src/test/kotlin/roomescape/member/infrastructure/persistence/MemberRepositoryTest.kt new file mode 100644 index 00000000..d5af4d07 --- /dev/null +++ b/src/test/kotlin/roomescape/member/infrastructure/persistence/MemberRepositoryTest.kt @@ -0,0 +1,63 @@ +package roomescape.member.infrastructure.persistence + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import jakarta.persistence.EntityManager +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import roomescape.util.MemberFixture + +@DataJpaTest(showSql = true) +class MemberRepositoryTest( + val entityManager: EntityManager, + val memberRepository: MemberRepository +) : FunSpec({ + context("existsByEmail") { + val account = "email" + val email = "$account@email.com" + + beforeTest { + entityManager.persist(MemberFixture.create(account = account)) + entityManager.flush() + entityManager.clear() + } + + test("동일한 이메일이 있으면 true 반환") { + val result = memberRepository.existsByEmail(email) + result shouldBe true + } + + test("동일한 이메일이 없으면 false 반환") { + memberRepository.existsByEmail(email.substring(email.length - 1)) shouldBe false + } + } + + context("findByEmailAndPassword") { + val account = "email" + val email = "$account@email.com" + val password = "password123" + + beforeTest { + entityManager.persist(MemberFixture.create(account = account, password = password)) + entityManager.flush() + entityManager.clear() + } + + test("둘다 일치하면 정상 반환") { + memberRepository.findByEmailAndPassword(email, password) shouldNotBeNull { + this.email shouldBe email + this.password shouldBe password + } + } + + test("이메일이 틀리면 null 반환") { + val invalidMail = email.substring(email.length - 1) + memberRepository.findByEmailAndPassword(invalidMail, password) shouldBe null + } + + test("비밀번호가 틀리면 null 반환") { + val invalidPassword = password.substring(password.length - 1) + memberRepository.findByEmailAndPassword(email, invalidPassword) shouldBe null + } + } +}) diff --git a/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt b/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt index 71d9696d..94ff5c8e 100644 --- a/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt +++ b/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt @@ -4,114 +4,162 @@ import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.every -import io.mockk.just import io.mockk.mockk -import io.mockk.runs +import io.mockk.slot import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.exception.PaymentException -import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository -import roomescape.payment.infrastructure.persistence.PaymentRepository +import roomescape.payment.implement.PaymentFinder +import roomescape.payment.implement.PaymentWriter +import roomescape.payment.infrastructure.client.PaymentApproveResponse +import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import roomescape.payment.web.PaymentCancelRequest +import roomescape.payment.web.PaymentCancelResponse import roomescape.util.PaymentFixture -import roomescape.util.TsidFactory +import roomescape.util.ReservationFixture +import java.time.LocalDate +import java.time.LocalTime import java.time.OffsetDateTime +import java.time.ZoneOffset class PaymentServiceTest : FunSpec({ - val paymentRepository: PaymentRepository = mockk() - val canceledPaymentRepository: CanceledPaymentRepository = mockk() + val paymentFinder: PaymentFinder = mockk() + val paymentWriter: PaymentWriter = mockk() - val paymentService = PaymentService(TsidFactory, paymentRepository, canceledPaymentRepository) + val paymentService = PaymentService(paymentFinder, paymentWriter) - context("createCanceledPaymentByReservationId") { - val reservationId = 1L - test("reservationId로 paymentKey를 찾을 수 없으면 예외를 던진다.") { - every { paymentRepository.findPaymentKeyByReservationId(reservationId) } returns null + context("createPayment") { + val approvedPaymentInfo = PaymentApproveResponse( + paymentKey = "paymentKey", + orderId = "orderId", + totalAmount = 1000L, + approvedAt = OffsetDateTime.now(), + ) + val reservation = ReservationFixture.create(id = 1L) - val exception = shouldThrow { - paymentService.createCanceledPaymentByReservationId(reservationId) + test("정상 응답") { + every { + paymentWriter.create( + paymentKey = approvedPaymentInfo.paymentKey, + orderId = approvedPaymentInfo.orderId, + totalAmount = approvedPaymentInfo.totalAmount, + approvedAt = approvedPaymentInfo.approvedAt, + reservation = reservation + ) + } returns PaymentFixture.create( + id = 1L, + orderId = approvedPaymentInfo.orderId, + paymentKey = approvedPaymentInfo.paymentKey, + totalAmount = approvedPaymentInfo.totalAmount, + approvedAt = approvedPaymentInfo.approvedAt, + reservation = reservation + ) + + val response = paymentService.createPayment(approvedPaymentInfo, reservation) + + assertSoftly(response) { + it.id shouldBe 1L + it.paymentKey shouldBe approvedPaymentInfo.paymentKey + it.reservationId shouldBe reservation.id } - exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND } + } - context("reservationId로 paymentKey를 찾고난 후") { - val paymentKey = "test-payment-key" + context("createCanceledPayment(canceledPaymentInfo)") { + val canceledPaymentInfo = PaymentCancelResponse( + cancelStatus = "normal", + cancelReason = "고객 요청", + cancelAmount = 1000L, + canceledAt = OffsetDateTime.now(), + ) + val approvedAt = OffsetDateTime.now() + val paymentKey = "paymentKey" + + test("CanceledPaymentEntity를 응답") { + every { + paymentWriter.createCanceled( + cancelReason = canceledPaymentInfo.cancelReason, + cancelAmount = canceledPaymentInfo.cancelAmount, + canceledAt = canceledPaymentInfo.canceledAt, + approvedAt = approvedAt, + paymentKey = paymentKey + ) + } returns PaymentFixture.createCanceled( + id = 1L, + paymentKey = paymentKey, + cancelAmount = canceledPaymentInfo.cancelAmount + ) + + val response = paymentService.createCanceledPayment(canceledPaymentInfo, approvedAt, paymentKey) + + response.shouldBeInstanceOf() + response.paymentKey shouldBe paymentKey + } + } + + context("createCanceledPayment(reservationId)") { + val reservationId = 1L + + test("취소 사유를 '예약 취소'로 하여 PaymentCancelRequest를 응답") { + val payment = PaymentFixture.create(id = 1L, paymentKey = "paymentKey", totalAmount = 1000L) + every { + paymentFinder.findByReservationId(reservationId) + } returns payment + + val cancelReasonSlot = slot() every { - paymentRepository.findPaymentKeyByReservationId(reservationId) - } returns paymentKey + paymentWriter.createCanceled(payment, capture(cancelReasonSlot), any()) + } returns PaymentFixture.createCanceled( + id = 1L, + paymentKey = payment.paymentKey, + cancelAmount = payment.totalAmount + ) - test("해당 paymentKey로 paymentEntity를 찾을 수 없으면 예외를 던진다.") { - every { - paymentRepository.findByPaymentKey(paymentKey) - } returns null + val response = paymentService.createCanceledPayment(reservationId) - val exception = shouldThrow { - paymentService.createCanceledPaymentByReservationId(reservationId) - } - exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND - } + response.shouldBeInstanceOf() + cancelReasonSlot.captured shouldBe "예약 취소" + } - test("해당 paymentKey로 paymentEntity를 찾고, cancelPaymentEntity를 저장한다.") { - val paymentEntity = PaymentFixture.create(paymentKey = paymentKey) + test("결제 정보가 없으면 예외 응답") { + every { + paymentFinder.findByReservationId(reservationId) + } throws PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) - every { - paymentRepository.findByPaymentKey(paymentKey) - } returns paymentEntity.also { - every { - paymentRepository.delete(it) - } just runs - } - - every { - canceledPaymentRepository.save(any()) - } returns PaymentFixture.createCanceled( - id = 1L, - paymentKey = paymentKey, - cancelReason = "Test", - cancelAmount = paymentEntity.totalAmount, - ) - - val result: PaymentCancelRequest = paymentService.createCanceledPaymentByReservationId(reservationId) - - assertSoftly(result) { - this.paymentKey shouldBe paymentKey - this.amount shouldBe paymentEntity.totalAmount - this.cancelReason shouldBe "Test" - } + shouldThrow { + paymentService.createCanceledPayment(reservationId) + }.also { + it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND } } } context("updateCanceledTime") { - val paymentKey = "test-payment-key" - val canceledAt = OffsetDateTime.now() + val paymentKey = "paymentKey" + val canceledAt = OffsetDateTime.of(LocalDate.of(2025, 8, 5), LocalTime.of(10, 0), ZoneOffset.UTC) - test("paymentKey로 canceledPaymentEntity를 찾을 수 없으면 예외를 던진다.") { + test("정상 응답") { + val canceled = PaymentFixture.createCanceled(id = 1L) every { - canceledPaymentRepository.findByPaymentKey(paymentKey) - } returns null - - val exception = shouldThrow { - paymentService.updateCanceledTime(paymentKey, canceledAt) - } - exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND - } - - test("paymentKey로 canceledPaymentEntity를 찾고, canceledAt을 업데이트한다.") { - val canceledPaymentEntity = PaymentFixture.createCanceled( - paymentKey = paymentKey, - canceledAt = canceledAt.minusMinutes(1) - ) - - every { - canceledPaymentRepository.findByPaymentKey(paymentKey) - } returns canceledPaymentEntity + paymentFinder.findCanceledByKey(paymentKey) + } returns canceled paymentService.updateCanceledTime(paymentKey, canceledAt) - assertSoftly(canceledPaymentEntity) { - this.canceledAt shouldBe canceledAt + canceled.canceledAt shouldBe canceledAt + } + + test("결제 취소 정보가 없으면 예외 응답") { + every { + paymentFinder.findCanceledByKey(paymentKey) + } throws PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) + + shouldThrow { + paymentService.updateCanceledTime(paymentKey, canceledAt) + }.also { + it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND } } } diff --git a/src/test/kotlin/roomescape/payment/implement/PaymentFinderTest.kt b/src/test/kotlin/roomescape/payment/implement/PaymentFinderTest.kt new file mode 100644 index 00000000..b5fd6d10 --- /dev/null +++ b/src/test/kotlin/roomescape/payment/implement/PaymentFinderTest.kt @@ -0,0 +1,93 @@ +package roomescape.payment.implement + +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 io.mockk.verify +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import roomescape.payment.infrastructure.persistence.PaymentRepository + +class PaymentFinderTest : FunSpec({ + val paymentRepository: PaymentRepository = mockk() + val canceledPaymentRepository: CanceledPaymentRepository = mockk() + + val paymentFinder = PaymentFinder(paymentRepository, canceledPaymentRepository) + + context("existsPaymentByReservationId") { + val reservationId = 1L + test("결제 정보가 있으면 true를 반환한다.") { + every { + paymentRepository.existsByReservationId(reservationId) + } returns true + + paymentFinder.existsPaymentByReservationId(reservationId) shouldBe true + } + + test("결제 정보가 없으면 false를 반환한다.") { + every { + paymentRepository.existsByReservationId(reservationId) + } returns false + + paymentFinder.existsPaymentByReservationId(reservationId) shouldBe false + } + } + + context("findByReservationId") { + val reservationId = 1L + test("결제 정보를 조회한다.") { + every { + paymentRepository.findByReservationId(reservationId) + } returns mockk() + + paymentFinder.findByReservationId(reservationId) + + verify(exactly = 1) { + paymentRepository.findByReservationId(reservationId) + } + } + + test("결제 정보가 없으면 실패한다.") { + every { + paymentRepository.findByReservationId(reservationId) + } returns null + + shouldThrow { + paymentFinder.findByReservationId(reservationId) + }.also { + it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND + } + } + } + + context("findCanceledByKey") { + val paymentKey = "paymentKey" + + test("결제 취소 정보를 조회한다.") { + every { + canceledPaymentRepository.findByPaymentKey(paymentKey) + } returns mockk() + + paymentFinder.findCanceledByKey(paymentKey) + + verify(exactly = 1) { + canceledPaymentRepository.findByPaymentKey(paymentKey) + } + } + + test("결제 취소 정보가 없으면 실패한다.") { + every { + canceledPaymentRepository.findByPaymentKey(paymentKey) + } returns null + + shouldThrow { + paymentFinder.findCanceledByKey(paymentKey) + }.also { + it.errorCode shouldBe PaymentErrorCode.CANCELED_PAYMENT_NOT_FOUND + } + } + } +}) diff --git a/src/test/kotlin/roomescape/payment/implement/PaymentWriterTest.kt b/src/test/kotlin/roomescape/payment/implement/PaymentWriterTest.kt new file mode 100644 index 00000000..ac003c06 --- /dev/null +++ b/src/test/kotlin/roomescape/payment/implement/PaymentWriterTest.kt @@ -0,0 +1,121 @@ +package roomescape.payment.implement + +import com.ninjasquad.springmockk.MockkClear +import com.ninjasquad.springmockk.clear +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.date.after +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import roomescape.payment.infrastructure.persistence.PaymentEntity +import roomescape.payment.infrastructure.persistence.PaymentRepository +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.util.PaymentFixture +import roomescape.util.ReservationFixture +import roomescape.util.TsidFactory +import java.time.OffsetDateTime + +class PaymentWriterTest : FunSpec({ + val paymentRepository: PaymentRepository = mockk() + val canceledPaymentRepository: CanceledPaymentRepository = mockk() + + val paymentWriter = PaymentWriter(paymentRepository, canceledPaymentRepository, TsidFactory) + + val paymentKey = "paymentKey" + val orderId = "orderId" + val totalAmount = 1000L + val approvedAt = OffsetDateTime.now() + + context("create") { + val reservation: ReservationEntity = ReservationFixture.create(id = 1L) + test("결제 정보를 저장한다.") { + val slot = slot() + every { + paymentRepository.save(capture(slot)) + } returns mockk() + + paymentWriter.create(paymentKey, orderId, totalAmount, approvedAt, reservation) + + verify(exactly = 1) { + paymentRepository.save(any()) + } + + slot.captured.also { + it.paymentKey shouldBe paymentKey + it.orderId shouldBe orderId + it.totalAmount shouldBe totalAmount + it.approvedAt shouldBe approvedAt + } + + paymentRepository.clear(MockkClear.AFTER) + } + } + + context("createCanceled") { + val cancelReason = "고객 요청" + val canceledAt = OffsetDateTime.now() + + afterTest { + clearMocks(paymentRepository) + clearMocks(canceledPaymentRepository) + } + + test("PaymentEntity를 받아 저장한다.") { + val payment: PaymentEntity = PaymentFixture.create(id = 1L) + val slot = slot() + + every { + canceledPaymentRepository.save(capture(slot)) + } returns mockk() + + every { + paymentRepository.deleteByPaymentKey(paymentKey) + } returns Unit + + paymentWriter.createCanceled(payment, cancelReason, canceledAt) + + verify(exactly = 1) { canceledPaymentRepository.save(any()) } + verify(exactly = 1) { paymentRepository.deleteByPaymentKey(any()) } + + slot.captured.also { + it.paymentKey shouldBe payment.paymentKey + it.cancelAmount shouldBe payment.totalAmount + it.approvedAt shouldBe payment.approvedAt + } + } + + test("취소 정보를 받아 저장한다.") { + val slot = slot() + + every { + canceledPaymentRepository.save(capture(slot)) + } returns mockk() + + every { + paymentRepository.deleteByPaymentKey(paymentKey) + } returns Unit + + paymentWriter.createCanceled( + cancelReason = cancelReason, + cancelAmount = totalAmount, + canceledAt = canceledAt, + approvedAt = approvedAt, + paymentKey = paymentKey + ) + + verify(exactly = 1) { canceledPaymentRepository.save(any()) } + verify(exactly = 1) { paymentRepository.deleteByPaymentKey(any()) } + + slot.captured.also { + it.paymentKey shouldBe paymentKey + it.cancelAmount shouldBe totalAmount + it.approvedAt shouldBe approvedAt + } + } + } +}) diff --git a/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt b/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt index eb8906ad..98419344 100644 --- a/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt +++ b/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt @@ -48,7 +48,6 @@ class TossPaymentClientTest( .createResponse(it) } - // when val paymentRequest = SampleTossPaymentConst.paymentRequest val paymentResponse: PaymentApproveResponse = client.confirm(paymentRequest) @@ -68,7 +67,6 @@ class TossPaymentClientTest( .createResponse(it) } - // when val paymentRequest = SampleTossPaymentConst.paymentRequest // then @@ -107,7 +105,7 @@ class TossPaymentClientTest( .createResponse(it) } - // when + val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest val cancelResponse: PaymentCancelResponse = client.cancel(cancelRequest) diff --git a/src/test/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt b/src/test/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt index 51979b53..a58d0580 100644 --- a/src/test/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt +++ b/src/test/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt @@ -38,58 +38,6 @@ class PaymentRepositoryTest( .also { it shouldBe false } } } - - context("findPaymentKeyByReservationId") { - lateinit var paymentKey: String - - beforeTest { - reservation = setupReservation() - paymentKey = PaymentFixture.create(reservation = reservation) - .also { paymentRepository.save(it) } - .paymentKey - } - - test("정상 반환") { - paymentRepository.findPaymentKeyByReservationId(reservation.id!!) - ?.let { it shouldBe paymentKey } - ?: throw AssertionError("Unexpected null value") - } - - test("null 반환") { - paymentRepository.findPaymentKeyByReservationId(reservation.id!! + 1) - .also { it shouldBe null } - } - } - - context("findByPaymentKey") { - lateinit var payment: PaymentEntity - - beforeTest { - reservation = setupReservation() - payment = PaymentFixture.create(reservation = reservation) - .also { paymentRepository.save(it) } - } - - test("정상 반환") { - paymentRepository.findByPaymentKey(payment.paymentKey) - ?.also { - assertSoftly(it) { - this.id shouldBe payment.id - this.orderId shouldBe payment.orderId - this.paymentKey shouldBe payment.paymentKey - this.totalAmount shouldBe payment.totalAmount - this.reservation.id shouldBe payment.reservation.id - this.approvedAt shouldBe payment.approvedAt - } - } - ?: throw AssertionError("Unexpected null value") - } - - test("null 반환") { - paymentRepository.findByPaymentKey("non-existent-key") - .also { it shouldBe null } - } - } } private fun setupReservation(): ReservationEntity { diff --git a/src/test/kotlin/roomescape/reservation/business/ReservationCommandServiceTest.kt b/src/test/kotlin/roomescape/reservation/business/ReservationCommandServiceTest.kt new file mode 100644 index 00000000..d75b2366 --- /dev/null +++ b/src/test/kotlin/roomescape/reservation/business/ReservationCommandServiceTest.kt @@ -0,0 +1,284 @@ +package roomescape.reservation.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.shouldBe +import io.mockk.* +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.implement.ReservationFinder +import roomescape.reservation.implement.ReservationWriter +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.util.MemberFixture +import roomescape.util.ReservationFixture +import roomescape.util.ThemeFixture +import roomescape.util.TimeFixture + +class ReservationCommandServiceTest : FunSpec({ + + val reservationFinder: ReservationFinder = mockk() + val reservationWriter: ReservationWriter = mockk() + val reservationWriteService = ReservationWriteService(reservationFinder, reservationWriter) + + context("createReservationWithPayment") { + val request = ReservationFixture.createRequest() + val memberId = 1L + + test("정상 응답") { + val createdReservation = ReservationFixture.create( + date = request.date, + time = TimeFixture.create(id = request.timeId), + theme = ThemeFixture.create(id = request.themeId), + member = MemberFixture.create(id = memberId), + status = ReservationStatus.CONFIRMED + ) + + every { + reservationWriter.create( + date = request.date, + timeId = request.timeId, + themeId = request.themeId, + status = ReservationStatus.CONFIRMED, + memberId = memberId, + requesterId = memberId + ) + } returns createdReservation + + val result = reservationWriteService.createReservationWithPayment(request, memberId) + + assertSoftly(result) { + this.date shouldBe request.date + this.time.id shouldBe request.timeId + this.theme.id shouldBe request.themeId + this.member.id shouldBe memberId + this.status shouldBe ReservationStatus.CONFIRMED + } + } + + test("예약 생성에 실패하면 예외 응답") { + every { + reservationWriter.create(any(), any(), any(), any(), any(), any()) + } throws ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED) + + shouldThrow { + reservationWriteService.createReservationWithPayment(request, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED + } + } + } + + context("createReservationByAdmin") { + val request = ReservationFixture.createAdminRequest() + val adminId = request.memberId + 1 + + test("정상 응답") { + val createdReservation = ReservationFixture.create( + date = request.date, + time = TimeFixture.create(id = request.timeId), + theme = ThemeFixture.create(id = request.themeId), + member = MemberFixture.create(id = request.memberId), + status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED + ) + + every { + reservationWriter.create( + date = request.date, + timeId = request.timeId, + themeId = request.themeId, + status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED, + memberId = request.memberId, + requesterId = adminId + ) + } returns createdReservation + + val response = reservationWriteService.createReservationByAdmin(request, adminId) + + assertSoftly(response) { + this.date shouldBe request.date + this.time.id shouldBe request.timeId + this.theme.id shouldBe request.themeId + this.member.id shouldBe request.memberId + this.status shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED + } + } + } + + context("createWaiting") { + val request = ReservationFixture.createWaitingRequest() + val memberId = 1L + + test("정상 응답") { + val createdWaiting = ReservationFixture.create( + date = request.date, + time = TimeFixture.create(id = request.timeId), + theme = ThemeFixture.create(id = request.themeId), + member = MemberFixture.create(id = memberId), + status = ReservationStatus.WAITING + ) + + every { + reservationWriter.create( + date = request.date, + timeId = request.timeId, + themeId = request.themeId, + status = ReservationStatus.WAITING, + memberId = memberId, + requesterId = memberId + ) + } returns createdWaiting + + val response = reservationWriteService.createWaiting(request, memberId) + + assertSoftly(response) { + this.date shouldBe request.date + this.time.id shouldBe request.timeId + this.theme.id shouldBe request.themeId + this.member.id shouldBe memberId + this.status shouldBe ReservationStatus.WAITING + } + } + + test("이미 예약한 내역이 있으면 예외 응답") { + every { + reservationWriter.create(any(), any(), any(), any(), any(), any()) + } throws ReservationException(ReservationErrorCode.ALREADY_RESERVE) + + shouldThrow { + reservationWriteService.createWaiting(request, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE + } + } + } + + context("deleteReservation") { + val reservationId = 1L + val memberId = 1L + val reservation = ReservationFixture.create(id = reservationId, member = MemberFixture.create(id = memberId)) + + test("정상 응답") { + every { reservationFinder.findById(reservationId) } returns reservation + every { reservationWriter.deleteConfirmed(reservation, memberId) } just Runs + + shouldNotThrow { + reservationWriteService.deleteReservation(reservationId, memberId) + } + + verify(exactly = 1) { reservationWriter.deleteConfirmed(reservation, memberId) } + } + + test("예약을 찾을 수 없으면 예외 응답") { + every { + reservationFinder.findById(reservationId) + } throws ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) + + shouldThrow { + reservationWriteService.deleteReservation(reservationId, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND + } + } + + test("삭제하려는 회원이 관리자가 아니고, 대기한 회원과 다르면 예외 응답") { + every { reservationFinder.findById(reservationId) } returns reservation + every { + reservationWriter.deleteConfirmed(reservation, memberId) + } throws ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) + + shouldThrow { + reservationWriteService.deleteReservation(reservationId, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER + } + } + } + + context("confirmWaiting") { + val reservationId = 1L + val memberId = 99L // Admin + + test("정상 응답") { + every { reservationWriter.confirm(reservationId) } just Runs + + shouldNotThrow { + reservationWriteService.confirmWaiting(reservationId, memberId) + } + + verify(exactly = 1) { reservationWriter.confirm(reservationId) } + } + + test("이미 확정된 예약이 있으면 예외 응답") { + every { + reservationWriter.confirm(reservationId) + } throws ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS) + + shouldThrow { + reservationWriteService.confirmWaiting(reservationId, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS + } + } + } + + context("deleteWaiting") { + val reservationId = 1L + val memberId = 1L + val waitingReservation = ReservationFixture.create( + id = reservationId, + member = MemberFixture.create(id = memberId), + status = ReservationStatus.WAITING + ) + + test("정상 응답") { + every { reservationFinder.findById(reservationId) } returns waitingReservation + every { reservationWriter.deleteWaiting(waitingReservation, memberId) } just Runs + + shouldNotThrow { + reservationWriteService.deleteWaiting(reservationId, memberId) + } + + verify(exactly = 1) { reservationWriter.deleteWaiting(waitingReservation, memberId) } + } + + test("예약을 찾을 수 없으면 예외 응답") { + every { + reservationFinder.findById(reservationId) + } throws ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) + + shouldThrow< ReservationException> { + reservationWriteService.deleteWaiting(reservationId, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND + } + } + + test("대기 상태가 아니면 예외 응답") { + every { reservationFinder.findById(reservationId) } returns waitingReservation + every { + reservationWriter.deleteWaiting(waitingReservation, memberId) + } throws ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) + + shouldThrow { + reservationWriteService.deleteWaiting(reservationId, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED + } + } + + test("삭제하려는 회원이 관리자가 아니고, 대기한 회원과 다르면 예외 응답") { + every { reservationFinder.findById(reservationId) } returns waitingReservation + every { + reservationWriter.deleteWaiting(waitingReservation, memberId) + } throws ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) + + shouldThrow { + reservationWriteService.deleteWaiting(reservationId, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER + } + } + } +}) diff --git a/src/test/kotlin/roomescape/reservation/business/ReservationQueryServiceTest.kt b/src/test/kotlin/roomescape/reservation/business/ReservationQueryServiceTest.kt new file mode 100644 index 00000000..782b149c --- /dev/null +++ b/src/test/kotlin/roomescape/reservation/business/ReservationQueryServiceTest.kt @@ -0,0 +1,118 @@ +package roomescape.reservation.business + +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 roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.implement.ReservationFinder +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.MyReservationRetrieveResponse +import roomescape.util.MemberFixture +import roomescape.util.ReservationFixture +import roomescape.util.ThemeFixture +import java.time.LocalDate + +class ReservationQueryServiceTest : FunSpec({ + + val reservationFinder: ReservationFinder = mockk() + val reservationFindService = ReservationFindService(reservationFinder) + + context("findReservations") { + test("정상 응답") { + val confirmedReservations = listOf( + ReservationFixture.create(status = ReservationStatus.CONFIRMED), + ReservationFixture.create(status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) + ) + every { + reservationFinder.findAllByStatuses(*ReservationStatus.confirmedStatus()) + } returns confirmedReservations + + val response = reservationFindService.findReservations() + + assertSoftly(response.reservations) { + this shouldHaveSize 2 + } + } + } + + context("findAllWaiting") { + test("정상 응답") { + val waitingReservations = listOf( + ReservationFixture.create(status = ReservationStatus.WAITING), + ReservationFixture.create(status = ReservationStatus.WAITING) + ) + every { + reservationFinder.findAllByStatuses(ReservationStatus.WAITING) + } returns waitingReservations + + val response = reservationFindService.findAllWaiting() + + assertSoftly(response.reservations) { + this shouldHaveSize 2 + } + } + } + + context("findReservationsByMemberId") { + val memberId = 1L + test("정상 응답") { + val myReservations = listOf(mockk(), mockk()) + + every { + reservationFinder.findAllByMemberId(memberId) + } returns myReservations + + val response = reservationFindService.findReservationsByMemberId(memberId) + + response.reservations shouldHaveSize 2 + } + } + + context("searchReservations") { + val themeId = 1L + val memberId = 1L + val startFrom = LocalDate.now() + val endAt = LocalDate.now().plusDays(1) + + test("정상 응답") { + val searchedReservations = listOf( + ReservationFixture.create( + theme = ThemeFixture.create(themeId), + member = MemberFixture.create(memberId), + date = startFrom + ) + ) + + every { + reservationFinder.searchReservations(themeId, memberId, startFrom, endAt) + } returns searchedReservations + + val response = reservationFindService.searchReservations(themeId, memberId, startFrom, endAt) + + assertSoftly(response.reservations) { + this shouldHaveSize 1 + this[0].theme.id shouldBe themeId + this[0].member.id shouldBe memberId + this[0].date shouldBe startFrom + } + } + + test("종료 날짜가 시작 날짜 이전이면 예외 응답") { + val invalidEndAt = startFrom.minusDays(1) + every { + reservationFinder.searchReservations(themeId, memberId, startFrom, invalidEndAt) + } throws ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE) + + shouldThrow { + reservationFindService.searchReservations(themeId, memberId, startFrom, invalidEndAt) + }.also { + it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE + } + } + } +}) diff --git a/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt b/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt deleted file mode 100644 index 54c6cccf..00000000 --- a/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt +++ /dev/null @@ -1,287 +0,0 @@ -package roomescape.reservation.business - -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 org.springframework.data.repository.findByIdOrNull -import roomescape.member.business.MemberService -import roomescape.member.infrastructure.persistence.Role -import roomescape.reservation.exception.ReservationErrorCode -import roomescape.reservation.exception.ReservationException -import roomescape.reservation.infrastructure.persistence.ReservationRepository -import roomescape.reservation.infrastructure.persistence.ReservationStatus -import roomescape.theme.business.ThemeService -import roomescape.time.business.TimeService -import roomescape.util.MemberFixture -import roomescape.util.ReservationFixture -import roomescape.util.TsidFactory -import roomescape.util.TimeFixture -import java.time.LocalDate -import java.time.LocalTime - -class ReservationServiceTest : FunSpec({ - - val reservationRepository: ReservationRepository = mockk() - val timeService: TimeService = mockk() - val memberService: MemberService = mockk() - val themeService: ThemeService = mockk() - val reservationService = ReservationService( - TsidFactory, - reservationRepository, - timeService, - memberService, - themeService - ) - - context("예약을 추가할 때") { - test("이미 예약이 있으면 예외를 던진다.") { - every { - reservationRepository.exists(any()) - } returns true - - val reservationRequest = ReservationFixture.createRequest() - - shouldThrow { - reservationService.createConfirmedReservation(reservationRequest, 1L) - }.also { - it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED - } - } - - context("날짜, 시간이 잘못 입력되면 예외를 던진다.") { - every { - reservationRepository.exists(any()) - } returns false - - every { - themeService.findById(any()) - } returns mockk() - - every { - memberService.findById(any()) - } returns mockk() - - - test("지난 날짜이면 예외를 던진다.") { - val reservationRequest = ReservationFixture.createRequest().copy( - date = LocalDate.now().minusDays(1) - ) - - every { - timeService.findById(any()) - } returns TimeFixture.create() - - shouldThrow { - reservationService.createConfirmedReservation(reservationRequest, 1L) - }.also { - it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME - } - } - - test("지난 시간이면 예외를 던진다.") { - val reservationRequest = ReservationFixture.createRequest().copy( - date = LocalDate.now(), - ) - - every { - timeService.findById(reservationRequest.timeId) - } returns TimeFixture.create( - startAt = LocalTime.now().minusMinutes(1) - ) - - shouldThrow { - reservationService.createConfirmedReservation(reservationRequest, 1L) - }.also { - it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME - } - } - } - } - - context("예약 대기를 걸 때") { - test("이미 예약한 회원이 같은 날짜와 테마로 대기를 걸면 예외를 던진다.") { - val reservationRequest = ReservationFixture.createRequest().copy( - date = LocalDate.now(), - themeId = 1L, - timeId = 1L, - ) - - every { - reservationRepository.exists(any()) - } returns true - - shouldThrow { - val waitingRequest = ReservationFixture.createWaitingRequest( - date = reservationRequest.date, - themeId = reservationRequest.themeId, - timeId = reservationRequest.timeId - ) - reservationService.createWaiting(waitingRequest, 1L) - }.also { - it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE - } - } - } - - context("예약 대기를 취소할 때") { - val reservationId = 1L - val member = MemberFixture.create(id = 1L, role = Role.MEMBER) - test("예약을 찾을 수 없으면 예외를 던진다.") { - every { - reservationRepository.findByIdOrNull(reservationId) - } returns null - - shouldThrow { - reservationService.deleteWaiting(reservationId, member.id!!) - }.also { - it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND - } - } - - test("대기중인 해당 예약이 이미 확정된 상태라면 예외를 던진다.") { - val alreadyConfirmed = ReservationFixture.create( - id = reservationId, - status = ReservationStatus.CONFIRMED - ) - every { - reservationRepository.findByIdOrNull(reservationId) - } returns alreadyConfirmed - - shouldThrow { - reservationService.deleteWaiting(reservationId, member.id!!) - }.also { - it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED - } - } - - test("타인의 대기를 취소하려고 하면 예외를 던진다.") { - val otherMembersWaiting = ReservationFixture.create( - id = reservationId, - member = MemberFixture.create(id = member.id!! + 1L), - status = ReservationStatus.WAITING - ) - - every { - reservationRepository.findByIdOrNull(reservationId) - } returns otherMembersWaiting - - shouldThrow { - reservationService.deleteWaiting(reservationId, member.id!!) - }.also { - it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER - } - } - } - - context("예약을 조회할 때") { - test("종료 날짜가 시작 날짜보다 이전이면 예외를 던진다.") { - val startFrom = LocalDate.now() - val endAt = startFrom.minusDays(1) - - shouldThrow { - reservationService.searchReservations( - null, - null, - startFrom, - endAt - ) - }.also { - it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE - } - } - } - - context("대기중인 예약을 승인할 때") { - test("관리자가 아니면 예외를 던진다.") { - val member = MemberFixture.create(id = 1L, role = Role.MEMBER) - - every { - memberService.findById(any()) - } returns member - - shouldThrow { - reservationService.confirmWaiting(1L, member.id!!) - }.also { - it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION - } - } - - test("이미 확정된 예약이 있으면 예외를 던진다.") { - val member = MemberFixture.create(id = 1L, role = Role.ADMIN) - val reservationId = 1L - - every { - memberService.findById(any()) - } returns member - - every { - reservationRepository.isExistConfirmedReservation(reservationId) - } returns true - - shouldThrow { - reservationService.confirmWaiting(reservationId, member.id!!) - }.also { - it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS - } - } - } - - context("대기중인 예약을 거절할 때") { - test("관리자가 아니면 예외를 던진다.") { - val member = MemberFixture.create(id = 1L, role = Role.MEMBER) - - every { - memberService.findById(any()) - } returns member - - shouldThrow { - reservationService.rejectWaiting(1L, member.id!!) - }.also { - it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION - } - } - - test("예약을 찾을 수 없으면 예외를 던진다.") { - val member = MemberFixture.create(id = 1L, role = Role.ADMIN) - val reservationId = 1L - - every { - memberService.findById(member.id!!) - } returns member - - every { - reservationRepository.findByIdOrNull(reservationId) - } returns null - - shouldThrow { - reservationService.rejectWaiting(reservationId, member.id!!) - }.also { - it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND - } - } - - test("이미 확정된 예약이면 예외를 던진다.") { - val member = MemberFixture.create(id = 1L, role = Role.ADMIN) - val reservation = ReservationFixture.create( - id = 1L, - status = ReservationStatus.CONFIRMED - ) - - every { - memberService.findById(member.id!!) - } returns member - - every { - reservationRepository.findByIdOrNull(reservation.id!!) - } returns reservation - - shouldThrow { - reservationService.rejectWaiting(reservation.id!!, member.id!!) - }.also { - it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED - } - } - } -}) diff --git a/src/test/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceTest.kt b/src/test/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceTest.kt index c8985f79..b3d0ed60 100644 --- a/src/test/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceTest.kt +++ b/src/test/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceTest.kt @@ -13,16 +13,16 @@ import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.toCreateResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.ReservationCreateResponse import roomescape.reservation.web.ReservationCreateWithPaymentRequest -import roomescape.reservation.web.ReservationRetrieveResponse import roomescape.util.* class ReservationWithPaymentServiceTest : FunSpec({ - val reservationService: ReservationService = mockk() + val reservationService: ReservationWriteService = mockk() val paymentService: PaymentService = mockk() val reservationWithPaymentService = ReservationWithPaymentService( - reservationService = reservationService, + reservationWriteService = reservationService, paymentService = paymentService ) @@ -48,16 +48,16 @@ class ReservationWithPaymentServiceTest : FunSpec({ context("addReservationWithPayment") { test("예약 및 결제 정보를 저장한다.") { every { - reservationService.createConfirmedReservation(reservationCreateWithPaymentRequest, memberId) + reservationService.createReservationWithPayment(reservationCreateWithPaymentRequest, memberId) } returns reservationEntity every { paymentService.createPayment(paymentApproveResponse, reservationEntity) } returns paymentEntity.toCreateResponse() - val result: ReservationRetrieveResponse = reservationWithPaymentService.createReservationAndPayment( + val result: ReservationCreateResponse = reservationWithPaymentService.createReservationAndPayment( request = reservationCreateWithPaymentRequest, - paymentInfo = paymentApproveResponse, + approvedPaymentInfo = paymentApproveResponse, memberId = memberId ) @@ -81,7 +81,7 @@ class ReservationWithPaymentServiceTest : FunSpec({ ) every { - paymentService.createCanceledPaymentByReservationId(reservationEntity.id!!) + paymentService.createCanceledPayment(reservationEntity.id!!) } returns paymentCancelRequest every { @@ -100,7 +100,7 @@ class ReservationWithPaymentServiceTest : FunSpec({ context("isNotPaidReservation") { test("결제된 예약이면 true를 반환한다.") { every { - paymentService.isReservationPaid(reservationEntity.id!!) + paymentService.existsByReservationId(reservationEntity.id!!) } returns false val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!) @@ -110,7 +110,7 @@ class ReservationWithPaymentServiceTest : FunSpec({ test("결제되지 않은 예약이면 false를 반환한다.") { every { - paymentService.isReservationPaid(reservationEntity.id!!) + paymentService.existsByReservationId(reservationEntity.id!!) } returns true val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!) diff --git a/src/test/kotlin/roomescape/reservation/implement/ReservationFinderTest.kt b/src/test/kotlin/roomescape/reservation/implement/ReservationFinderTest.kt new file mode 100644 index 00000000..e11946d3 --- /dev/null +++ b/src/test/kotlin/roomescape/reservation/implement/ReservationFinderTest.kt @@ -0,0 +1,64 @@ +package roomescape.reservation.implement + +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 io.mockk.spyk +import io.mockk.verify +import org.springframework.data.jpa.domain.Specification +import org.springframework.data.repository.findByIdOrNull +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.time.exception.TimeErrorCode +import roomescape.time.exception.TimeException +import java.time.LocalDate + +class ReservationFinderTest : FunSpec({ + val reservationRepository: ReservationRepository = mockk() + val reservationValidator = ReservationValidator(reservationRepository) + + val reservationFinder = ReservationFinder(reservationRepository, reservationValidator) + + context("findById") { + val reservationId = 1L + test("동일한 ID인 시간을 찾아 응답한다.") { + every { + reservationRepository.findByIdOrNull(reservationId) + } returns mockk() + + reservationFinder.findById(reservationId) + + verify(exactly = 1) { + reservationRepository.findByIdOrNull(reservationId) + } + } + + test("동일한 ID인 시간이 없으면 실패한다.") { + every { + reservationRepository.findByIdOrNull(reservationId) + } returns null + + shouldThrow { + reservationFinder.findById(reservationId) + }.also { + it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND + } + } + } + + context("searchReservations") { + test("시작 날짜가 종료 날짜 이전이면 실패한다.") { + shouldThrow { + reservationFinder.searchReservations(1L, 1L, LocalDate.now(), LocalDate.now().minusDays(1)) + }.also { + it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE + } + } + } +}) diff --git a/src/test/kotlin/roomescape/reservation/implement/ReservationValidatorTest.kt b/src/test/kotlin/roomescape/reservation/implement/ReservationValidatorTest.kt new file mode 100644 index 00000000..878a5cc0 --- /dev/null +++ b/src/test/kotlin/roomescape/reservation/implement/ReservationValidatorTest.kt @@ -0,0 +1,170 @@ +package roomescape.reservation.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 org.springframework.data.jpa.domain.Specification +import roomescape.member.infrastructure.persistence.Role +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.util.MemberFixture +import roomescape.util.ReservationFixture +import roomescape.util.ThemeFixture +import roomescape.util.TimeFixture +import java.time.LocalDate +import java.time.LocalTime + +class ReservationValidatorTest : FunSpec({ + val reservationRepository: ReservationRepository = mockk() + + val reservationValidator = ReservationValidator(reservationRepository) + + context("validateIsNotPast") { + val today = LocalDate.now() + val now = LocalTime.now() + + test("입력된 날짜가 오늘 이전이면 예외를 던진다.") { + val requestDate = today.minusDays(1) + + shouldThrow { + reservationValidator.validateIsPast(requestDate, now) + }.also { + it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME + } + } + + test("오늘 날짜라도 시간이 지났다면 예외를 던진다.") { + val requestTime = now.minusMinutes(1) + + shouldThrow { + reservationValidator.validateIsPast(today, requestTime) + }.also { + it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME + } + } + } + + context("validateSearchDateRange") { + test("시작 날짜만 입력되면 종료한다.") { + shouldNotThrow { + reservationValidator.validateSearchDateRange(LocalDate.now(), null) + } + } + + test("종료 날짜만 입력되면 종료한다.") { + shouldNotThrow { + reservationValidator.validateSearchDateRange(null, LocalDate.now()) + } + } + + test("두 날짜가 같으면 종료한다.") { + shouldNotThrow { + reservationValidator.validateSearchDateRange(LocalDate.now(), LocalDate.now()) + } + } + + test("종료 날짜가 시작 날짜 이전이면 예외를 던진다.") { + shouldThrow { + reservationValidator.validateSearchDateRange(LocalDate.now(), LocalDate.now().minusDays(1)) + }.also { + it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE + } + } + } + + context("validateIsAlreadyExists") { + test("동일한 날짜, 시간, 테마를 가지는 예약이 있으면 예외를 던진다.") { + every { + reservationRepository.exists(any>()) + } returns true + + shouldThrow { + reservationValidator.validateIsAlreadyExists( + LocalDate.now(), + TimeFixture.create(), + ThemeFixture.create() + ) + }.also { + it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED + } + } + } + + context("validateMemberAlreadyReserve") { + test("회원이 동일한 날짜, 시간, 테마인 예약(대기)를 이미 했다면 예외를 던진다.") { + every { + reservationRepository.exists(any>()) + } returns true + + shouldThrow { + reservationValidator.validateMemberAlreadyReserve(1L, 1L, LocalDate.now(), 1L) + }.also { + it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE + } + } + } + + context("validateIsWaiting") { + test("예약 상태가 WAITING이 아니면 예외를 던진다.") { + ReservationStatus.confirmedStatus().forEach { status -> + shouldThrow { + val reservation = ReservationFixture.create(status = status) + reservationValidator.validateIsWaiting(reservation) + }.also { + it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED + } + } + } + } + + context("validateCreateAuthority") { + test("관리자가 아니면 예외를 던진다.") { + shouldThrow { + reservationValidator.validateCreateAuthority(MemberFixture.user()) + }.also { + it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION + } + } + } + + context("validateDeleteAuthority") { + test("입력된 회원이 관리자이면 종료한다.") { + shouldNotThrow { + reservationValidator.validateDeleteAuthority(mockk(), MemberFixture.admin()) + } + } + + test("입력된 회원이 관리자가 아니고, 예약한 회원과 다른 회원이면 예외를 던진다.") { + shouldThrow { + reservationValidator.validateDeleteAuthority( + ReservationFixture.create(member = MemberFixture.create(id = 1L)), + MemberFixture.create(id = 2L, role = Role.MEMBER) + ) + }.also { + it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER + } + } + } + + context("validateAlreadyConfirmed") { + val reservationId = 1L + + test("입력된 ID의 예약과 동일한 날짜, 시간, 테마를 가지는 다른 확정 예약이 있으면 예외를 던진다.") { + every { + reservationRepository.isExistConfirmedReservation(reservationId) + } returns true + + shouldThrow { + reservationValidator.validateAlreadyConfirmed(reservationId) + }.also { + it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS + } + } + } +}) diff --git a/src/test/kotlin/roomescape/reservation/implement/ReservationWriterTest.kt b/src/test/kotlin/roomescape/reservation/implement/ReservationWriterTest.kt new file mode 100644 index 00000000..2fe07bce --- /dev/null +++ b/src/test/kotlin/roomescape/reservation/implement/ReservationWriterTest.kt @@ -0,0 +1,246 @@ +package roomescape.reservation.implement + +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.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.implement.MemberFinder +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationStatus +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.implement.TimeFinder +import roomescape.util.MemberFixture +import roomescape.util.ThemeFixture +import roomescape.util.TimeFixture +import roomescape.util.TsidFactory +import java.time.LocalDate +import java.time.LocalTime + +class ReservationWriterTest : FunSpec({ + + val reservationValidator: ReservationValidator = mockk() + val reservationRepository: ReservationRepository = mockk() + val memberFinder: MemberFinder = mockk() + val timeFinder: TimeFinder = mockk() + val themeFinder: ThemeFinder = mockk() + + val reservationWriter = ReservationWriter( + reservationValidator, reservationRepository, memberFinder, timeFinder, themeFinder, TsidFactory + ) + + context("create") { + val today = LocalDate.now() + val timeId = 1L + val themeId = 1L + val memberId = 1L + val status = ReservationStatus.CONFIRMED + val requesterId = 1L + + test("시간을 찾을 수 없으면 실패한다.") { + every { + timeFinder.findById(any()) + } throws TimeException(TimeErrorCode.TIME_NOT_FOUND) + + shouldThrow { + reservationWriter.create(today, timeId, themeId, memberId, status, requesterId) + }.also { + it.errorCode shouldBe TimeErrorCode.TIME_NOT_FOUND + } + } + + test("이전 날짜이면 실패한다.") { + every { + timeFinder.findById(timeId) + } returns TimeFixture.create(id = timeId, startAt = LocalTime.now().plusHours(1)) + + every { + reservationValidator.validateIsPast(any(), any()) + } throws ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME) + + shouldThrow { + reservationWriter.create(today.minusDays(1), timeId, themeId, memberId, status, requesterId) + }.also { + it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME + } + } + + test("테마를 찾을 수 없으면 실패한다.") { + every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId) + every { reservationValidator.validateIsPast(any(), any()) } returns Unit + + every { + themeFinder.findById(themeId) + } throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + + shouldThrow { + reservationWriter.create(today.plusDays(1), timeId, themeId, memberId, status, requesterId) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND + } + } + + test("회원을 찾을 수 없으면 실패한다.") { + every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId) + every { reservationValidator.validateIsPast(any(), any()) } returns Unit + every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId) + + every { + memberFinder.findById(memberId) + } throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND) + + shouldThrow { + reservationWriter.create(today.plusDays(1), timeId, themeId, memberId, status, requesterId) + }.also { + it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND + } + } + + test("이미 예약이 있는 회원이 대기를 추가하면 실패한다.") { + every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId) + every { reservationValidator.validateIsPast(any(), any()) } returns Unit + every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId) + every { memberFinder.findById(memberId) } returns MemberFixture.create(id = memberId) + + every { + reservationValidator.validateMemberAlreadyReserve(themeId, timeId, today, memberId) + } throws ReservationException(ReservationErrorCode.ALREADY_RESERVE) + + shouldThrow { + reservationWriter.create(today, timeId, themeId, memberId, status = ReservationStatus.WAITING, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE + } + } + + test("동일한 날짜, 시간, 테마인 예약이 이미 있으면 실패한다.") { + every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId) + every { reservationValidator.validateIsPast(any(), any()) } returns Unit + every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId) + every { memberFinder.findById(memberId) } returns MemberFixture.create(id = memberId) + + every { + reservationValidator.validateIsAlreadyExists(today, any(), any()) + } throws ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED) + + shouldThrow { + reservationWriter.create(today, timeId, themeId, memberId, status, memberId) + }.also { + it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED + } + } + + test("예약하려는 회원과 신청한 회원이 다를 때, 신청한 회원을 찾을 수 없으면 실패한다.") { + every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId) + every { reservationValidator.validateIsPast(any(), any()) } returns Unit + every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId) + every { memberFinder.findById(memberId) } returns MemberFixture.create(id = memberId) + every { reservationValidator.validateIsAlreadyExists(today, any(), any()) } returns Unit + + every { + memberFinder.findById(memberId + 1) + } throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND) + + shouldThrow { + reservationWriter.create(today, timeId, themeId, memberId = memberId, status, requesterId = (memberId + 1)) + }.also { + it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND + } + } + + test("예약하려는 회원과 신청한 회원이 다를 때, 신청한 회원이 관리자가 아니면 실패한다.") { + every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId) + every { reservationValidator.validateIsPast(any(), any()) } returns Unit + every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId) + every { memberFinder.findById(memberId) } returns MemberFixture.create(id = memberId) + every { reservationValidator.validateIsAlreadyExists(today, any(), any()) } returns Unit + + every { + memberFinder.findById(memberId + 1) + } returns MemberFixture.create(id = memberId + 1) + + every { + reservationValidator.validateCreateAuthority(any()) + } throws ReservationException(ReservationErrorCode.NO_PERMISSION) + + shouldThrow { + reservationWriter.create(today, timeId, themeId, memberId = memberId, status, requesterId = (memberId + 1)) + }.also { + it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION + } + } + } + + context("deleteWaiting") { + val reservation: ReservationEntity = mockk() + val requesterId = 1L + + test("대기 상태가 아니면 실패한다.") { + every { + reservationValidator.validateIsWaiting(any()) + } throws ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) + + shouldThrow { + reservationWriter.deleteWaiting(reservation, requesterId) + }.also { + it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED + } + } + + test("삭제하려는 회원이 관리자가 아니고, 예약한 회원과 다르면 실패한다.") { + every { reservationValidator.validateIsWaiting(any()) } returns Unit + every { + reservationValidator.validateDeleteAuthority(any(), any()) + } throws ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) + + shouldThrow { + reservationWriter.deleteWaiting(reservation, requesterId) + }.also { + it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER + } + } + } + + context("deleteConfirm") { + val reservation: ReservationEntity = mockk() + val requesterId = 1L + + test("삭제하려는 회원이 관리자가 아니고, 예약한 회원과 다르면 실패한다.") { + every { reservationValidator.validateIsWaiting(any()) } returns Unit + every { + reservationValidator.validateDeleteAuthority(any(), any()) + } throws ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) + + shouldThrow { + reservationWriter.deleteConfirmed(reservation, requesterId) + }.also { + it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER + } + } + } + + context("confirm") { + val reservationId = 1L + + test("승인하려는 대기와 같은 날짜,시간,테마를 가진 확정 예약이 있으면 실패한다.") { + every { + reservationValidator.validateAlreadyConfirmed(reservationId) + } throws ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS) + + shouldThrow { + reservationWriter.confirm(reservationId) + }.also { + it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS + } + } + } +}) diff --git a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt index 88323f85..c9cf5570 100644 --- a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt +++ b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt @@ -81,6 +81,21 @@ class ReservationSearchSpecificationTest( } } + "여러 상태를 입력받아 같은 상태안 예약을 조회한다." { + val spec = ReservationSearchSpecification() + .status( + ReservationStatus.CONFIRMED, + ReservationStatus.CONFIRMED_PAYMENT_REQUIRED, + ReservationStatus.WAITING + ).build() + + val results: List = reservationRepository.findAll(spec) + + assertSoftly(results) { + this shouldHaveSize reservationRepository.findAll().size + } + } + "확정 상태인 예약을 조회한다" { val spec = ReservationSearchSpecification() .confirmed() diff --git a/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt b/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt index 4cecc9d1..b576c26b 100644 --- a/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt +++ b/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt @@ -1,87 +1,108 @@ package roomescape.theme.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.shouldBe -import io.mockk.every -import io.mockk.mockk -import org.springframework.data.repository.findByIdOrNull +import io.mockk.* import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeException +import roomescape.theme.implement.ThemeFinder +import roomescape.theme.implement.ThemeWriter import roomescape.theme.infrastructure.persistence.ThemeEntity -import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.web.ThemeCreateRequest -import roomescape.theme.web.ThemeRetrieveResponse -import roomescape.util.TsidFactory +import roomescape.theme.web.ThemeCreateResponse import roomescape.util.ThemeFixture +import java.time.LocalDate class ThemeServiceTest : FunSpec({ + val themeFinder: ThemeFinder = mockk() + val themeWriter: ThemeWriter = mockk() - val themeRepository: ThemeRepository = mockk() - val themeService = ThemeService(TsidFactory, themeRepository) + val themeService = ThemeService(themeFinder, themeWriter) context("findThemeById") { val themeId = 1L - test("조회 성공") { + + test("정상 응답") { val theme: ThemeEntity = ThemeFixture.create(id = themeId) every { - themeRepository.findByIdOrNull(themeId) + themeFinder.findById(themeId) } returns theme theme.id shouldBe themeId } - test("ID로 테마를 찾을 수 없으면 400 예외를 던진다.") { + test("테마를 찾을 수 없으면 예외 응답") { every { - themeRepository.findByIdOrNull(themeId) - } returns null + themeFinder.findById(themeId) + } throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND) - val exception = shouldThrow { + shouldThrow { themeService.findById(themeId) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND } - - exception.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND } } - context("findAllThemes") { - test("모든 테마를 조회한다.") { - val themes = listOf(ThemeFixture.create(id = 1, name = "t1"), ThemeFixture.create(id = 2, name = "t2")) + context("findThemes") { + test("정상 응답") { + val themes = listOf( + ThemeFixture.create(id = 1, name = "t1"), + ThemeFixture.create(id = 2, name = "t2") + ) + every { - themeRepository.findAll() + themeFinder.findAll() } returns themes - assertSoftly(themeService.findThemes()) { - this.themes.size shouldBe themes.size - this.themes[0].name shouldBe "t1" - this.themes[1].name shouldBe "t2" + val response = themeService.findThemes() + + assertSoftly(response.themes) { + this.size shouldBe themes.size + it.map { theme -> theme.name } shouldContainExactly themes.map { theme -> theme.name } } } } - context("save") { + context("findMostReservedThemes") { + test("7일 전 부터 1일 전 까지 조회") { + val count = 10 + val startFrom = slot() + val endAt = slot() + + every { + themeFinder.findMostReservedThemes(count, capture(startFrom), capture(endAt)) + } returns emptyList() + + themeService.findMostReservedThemes(count) + + startFrom.captured shouldBe LocalDate.now().minusDays(7) + endAt.captured shouldBe LocalDate.now().minusDays(1) + } + } + + context("createTheme") { val request = ThemeCreateRequest( name = "New Theme", description = "Description", thumbnail = "http://example.com/thumbnail.jpg" ) - test("저장 성공") { + test("정상 저장") { every { - themeRepository.existsByName(request.name) - } returns false - - every { - themeRepository.save(any()) + themeWriter.create(request.name, request.description, request.thumbnail) } returns ThemeFixture.create( - id = 1L, + id = 1, name = request.name, description = request.description, thumbnail = request.thumbnail ) - val response: ThemeRetrieveResponse = themeService.createTheme(request) + val response: ThemeCreateResponse = themeService.createTheme(request) assertSoftly(response) { this.id shouldBe 1L @@ -91,32 +112,51 @@ class ThemeServiceTest : FunSpec({ } } - test("테마 이름이 중복되면 409 예외를 던진다.") { + test("중복된 이름이 있으면 예외 응답") { every { - themeRepository.existsByName(request.name) - } returns true + themeWriter.create(request.name, request.description, request.thumbnail) + } throws ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) - val exception = shouldThrow { + shouldThrow { themeService.createTheme(request) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED } - - exception.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED } } context("deleteById") { - test("이미 예약 중인 테마라면 409 예외를 던진다.") { - val themeId = 1L + val themeId = 1L + val theme: ThemeEntity = ThemeFixture.create(id = themeId) - every { - themeRepository.isReservedTheme(themeId) - } returns true + test("정상 응답") { + every { themeFinder.findById(themeId) } returns theme + every { themeWriter.delete(theme) } just Runs - val exception = shouldThrow { + shouldNotThrow { themeService.deleteTheme(themeId) } + } - exception.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED + test("테마를 찾을 수 없으면 예외 응답") { + every { themeFinder.findById(themeId) } throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + + shouldThrow { + themeService.deleteTheme(themeId) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND + } + } + + test("예약이 있는 테마이면 예외 응답") { + every { themeFinder.findById(themeId) } returns theme + every { themeWriter.delete(theme) } throws ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED) + + shouldThrow { + themeService.deleteTheme(themeId) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED + } } } }) diff --git a/src/test/kotlin/roomescape/theme/implement/ThemeFinderTest.kt b/src/test/kotlin/roomescape/theme/implement/ThemeFinderTest.kt new file mode 100644 index 00000000..c41ef15a --- /dev/null +++ b/src/test/kotlin/roomescape/theme/implement/ThemeFinderTest.kt @@ -0,0 +1,76 @@ +package roomescape.theme.implement + +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.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException +import roomescape.theme.infrastructure.persistence.ThemeRepository +import roomescape.util.ThemeFixture +import java.time.LocalDate + +class ThemeFinderTest : FunSpec({ + val themeRepository: ThemeRepository = mockk() + + val themeFinder = ThemeFinder(themeRepository) + + context("findAll") { + test("모든 테마를 조회한다.") { + every { + themeRepository.findAll() + } returns listOf(mockk(), mockk(), mockk()) + + themeRepository.findAll() shouldHaveSize 3 + } + } + + context("findById") { + val timeId = 1L + test("동일한 ID인 테마를 찾아 응답한다.") { + every { + themeRepository.findByIdOrNull(timeId) + } returns mockk() + + themeFinder.findById(timeId) + + verify(exactly = 1) { + themeRepository.findByIdOrNull(timeId) + } + } + + test("동일한 ID인 테마가 없으면 실패한다.") { + every { + themeRepository.findByIdOrNull(timeId) + } returns null + + shouldThrow { + themeFinder.findById(timeId) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND + } + } + } + + context("findMostReservedThemes") { + test("입력된 개수보다 조회된 개수가 작으면 조회된 개수만큼 반환한다.") { + val count = 10 + val startFrom = LocalDate.now().minusDays(7) + val endAt = LocalDate.now().minusDays(1) + + every { + themeRepository.findPopularThemes(startFrom, endAt, count) + } returns listOf( + ThemeFixture.create(id = 1, name = "name1"), + ThemeFixture.create(id = 2, name = "name2"), + ThemeFixture.create(id = 3, name = "name3"), + ) + + themeFinder.findMostReservedThemes(count, startFrom, endAt) shouldHaveSize 3 + } + } +}) diff --git a/src/test/kotlin/roomescape/theme/implement/ThemeValidatorTest.kt b/src/test/kotlin/roomescape/theme/implement/ThemeValidatorTest.kt new file mode 100644 index 00000000..175028dd --- /dev/null +++ b/src/test/kotlin/roomescape/theme/implement/ThemeValidatorTest.kt @@ -0,0 +1,79 @@ +package roomescape.theme.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.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.theme.infrastructure.persistence.ThemeRepository +import roomescape.util.ThemeFixture + +class ThemeValidatorTest : FunSpec({ + val themeRepository: ThemeRepository = mockk() + + val themeValidator = ThemeValidator(themeRepository) + + context("validateNameAlreadyExists") { + val name = "name" + + test("같은 이름을 가진 테마가 있으면 예외를 던진다.") { + every { + themeRepository.existsByName(name) + } returns true + + shouldThrow { + themeValidator.validateNameAlreadyExists(name) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED + } + } + + test("같은 이름을 가진 테마가 없으면 종료한다.") { + every { + themeRepository.existsByName(name) + } returns false + + shouldNotThrow { + themeValidator.validateNameAlreadyExists(name) + } + } + } + + context("validateIsReserved") { + test("입력된 id가 null 이면 예외를 던진다.") { + shouldThrow { + themeValidator.validateIsReserved(ThemeFixture.create(id = null)) + }.also { + it.errorCode shouldBe ThemeErrorCode.INVALID_REQUEST_VALUE + } + } + + val theme: ThemeEntity = ThemeFixture.create(id = 1L, name = "name") + + test("예약이 있는 테마이면 예외를 던진다.") { + every { + themeRepository.isReservedTheme(theme.id!!) + } returns true + + shouldThrow { + themeValidator.validateIsReserved(theme) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED + } + } + + test("예약이 없는 테마이면 종료한다.") { + every { + themeRepository.isReservedTheme(theme.id!!) + } returns false + + shouldNotThrow { + themeValidator.validateIsReserved(theme) + } + } + } +}) diff --git a/src/test/kotlin/roomescape/theme/implement/ThemeWriterTest.kt b/src/test/kotlin/roomescape/theme/implement/ThemeWriterTest.kt new file mode 100644 index 00000000..eacfd714 --- /dev/null +++ b/src/test/kotlin/roomescape/theme/implement/ThemeWriterTest.kt @@ -0,0 +1,86 @@ +package roomescape.theme.implement + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.* +import roomescape.common.config.next +import roomescape.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.theme.infrastructure.persistence.ThemeRepository +import roomescape.util.ThemeFixture +import roomescape.util.TsidFactory + +class ThemeWriterTest : FunSpec({ + + val themeValidator: ThemeValidator = mockk() + val themeRepository: ThemeRepository = mockk() + + val themeWriter = ThemeWriter(themeValidator, themeRepository, TsidFactory) + + context("create") { + val name = "name" + val description = "description" + val thumbnail = "thumbnail" + + test("중복된 이름이 있으면 실패한다.") { + every { + themeValidator.validateNameAlreadyExists(name) + } throws ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) + + shouldThrow { + themeWriter.create(name, description, thumbnail) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED + } + } + + test("중복된 이름이 없으면 저장한다.") { + every { + themeValidator.validateNameAlreadyExists(name) + } just Runs + + every { + themeRepository.save(any()) + } returns ThemeFixture.create(name = name, description = description, thumbnail = thumbnail) + + themeWriter.create(name, description, thumbnail) + + verify(exactly = 1) { + themeRepository.save(any()) + } + } + } + + context("delete") { + val theme: ThemeEntity = ThemeFixture.create(id = TsidFactory.next()) + test("예약이 있는 테마이면 실패한다.") { + every { + themeValidator.validateIsReserved(theme) + } throws ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED) + + shouldThrow { + themeWriter.delete(theme) + }.also { + it.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED + } + } + + test("예약이 없는 테마이면 제거한다.") { + every { + themeValidator.validateIsReserved(theme) + } just Runs + + every { + themeRepository.delete(theme) + } just Runs + + themeWriter.delete(theme) + + verify(exactly = 1) { + themeRepository.delete(theme) + } + } + } +}) diff --git a/src/test/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepositoryTest.kt b/src/test/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepositoryTest.kt index 2784f4ea..6b8e6aa8 100644 --- a/src/test/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepositoryTest.kt +++ b/src/test/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepositoryTest.kt @@ -5,7 +5,7 @@ import io.kotest.matchers.collections.shouldContainInOrder import io.kotest.matchers.shouldBe import jakarta.persistence.EntityManager import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import roomescape.theme.util.TestThemeCreateUtil +import roomescape.theme.util.TestThemeDataHelper import java.time.LocalDate @DataJpaTest(showSql = false) @@ -14,12 +14,13 @@ class ThemeRepositoryTest( val entityManager: EntityManager ) : FunSpec() { + val helper = TestThemeDataHelper(entityManager, transactionTemplate = null) + init { context("findTopNThemeBetweenStartDateAndEndDate") { beforeTest { for (i in 1..10) { - TestThemeCreateUtil.createThemeWithReservations( - entityManager = entityManager, + helper.createThemeWithReservations( name = "테마$i", reservedCount = i, date = LocalDate.now().minusDays(i.toLong()), @@ -27,7 +28,7 @@ class ThemeRepositoryTest( } } - test("지난 10일간 예약 수가 가장 많은 테마 5개를 조회한다.") { + test("지난 10일간 예약 수가 가장 많은 테마 5개를 조회") { themeRepository.findPopularThemes( LocalDate.now().minusDays(10), LocalDate.now().minusDays(1), @@ -40,7 +41,7 @@ class ThemeRepositoryTest( } } - test("8일 전부터 5일 전까지 예약 수가 가장 많은 테마 3개를 조회한다.") { + test("8일 전부터 5일 전까지 예약 수가 가장 많은 테마 3개를 조회") { themeRepository.findPopularThemes( LocalDate.now().minusDays(8), LocalDate.now().minusDays(5), @@ -53,9 +54,8 @@ class ThemeRepositoryTest( } } - test("예약 수가 동일하면 먼저 생성된 테마를 우선 조회한다.") { - TestThemeCreateUtil.createThemeWithReservations( - entityManager = entityManager, + test("예약 수가 동일하면 먼저 생성된 테마를 우선 조회") { + helper.createThemeWithReservations( name = "테마11", reservedCount = 5, date = LocalDate.now().minusDays(5), @@ -73,7 +73,7 @@ class ThemeRepositoryTest( } } - test("입력된 갯수보다 조회된 갯수가 작으면, 조회된 갯수만큼 반환한다.") { + test("입력된 갯수보다 조회된 갯수가 작으면, 조회된 갯수만큼 반환") { themeRepository.findPopularThemes( LocalDate.now().minusDays(10), LocalDate.now().minusDays(6), @@ -83,7 +83,7 @@ class ThemeRepositoryTest( } } - test("입력된 갯수보다 조회된 갯수가 많으면, 입력된 갯수만큼 반환한다.") { + test("입력된 갯수보다 조회된 갯수가 많으면, 입력된 갯수만큼 반환") { themeRepository.findPopularThemes( LocalDate.now().minusDays(10), LocalDate.now().minusDays(1), @@ -93,7 +93,7 @@ class ThemeRepositoryTest( } } - test("입력된 날짜 범위에 예약된 테마가 없을 경우 빈 리스트를 반환한다.") { + test("입력된 날짜 범위에 예약된 테마가 없을 경우 빈 리스트 반환") { themeRepository.findPopularThemes( LocalDate.now().plusDays(1), LocalDate.now().plusDays(10), @@ -106,26 +106,24 @@ class ThemeRepositoryTest( context("existsByName ") { val themeName = "test-theme" beforeTest { - TestThemeCreateUtil.createThemeWithReservations( - entityManager = entityManager, + helper.createThemeWithReservations( name = themeName, reservedCount = 0, date = LocalDate.now() ) } - test("테마 이름이 존재하면 true를 반환한다.") { + test("테마 이름이 존재하면 true 반환") { themeRepository.existsByName(themeName) shouldBe true } - test("테마 이름이 존재하지 않으면 false를 반환한다.") { + test("테마 이름이 존재하지 않으면 false 반환") { themeRepository.existsByName(themeName.repeat(2)) shouldBe false } } context("isReservedTheme") { - test("테마가 예약 중이면 true를 반환한다.") { - val theme = TestThemeCreateUtil.createThemeWithReservations( - entityManager = entityManager, + test("테마가 예약 중이면 true 반환") { + val theme = helper.createThemeWithReservations( name = "예약된 테마", reservedCount = 1, date = LocalDate.now() @@ -133,9 +131,8 @@ class ThemeRepositoryTest( themeRepository.isReservedTheme(theme.id!!) shouldBe true } - test("테마가 예약 중이 아니면 false를 반환한다.") { - val theme = TestThemeCreateUtil.createThemeWithReservations( - entityManager = entityManager, + test("테마가 예약 중이 아니면 false 반환") { + val theme = helper.createThemeWithReservations( name = "예약되지 않은 테마", reservedCount = 0, date = LocalDate.now() diff --git a/src/test/kotlin/roomescape/theme/util/TestThemeCreateUtil.kt b/src/test/kotlin/roomescape/theme/util/TestThemeDataHelper.kt similarity index 58% rename from src/test/kotlin/roomescape/theme/util/TestThemeCreateUtil.kt rename to src/test/kotlin/roomescape/theme/util/TestThemeDataHelper.kt index 72381b78..9df5f6a3 100644 --- a/src/test/kotlin/roomescape/theme/util/TestThemeCreateUtil.kt +++ b/src/test/kotlin/roomescape/theme/util/TestThemeDataHelper.kt @@ -1,6 +1,7 @@ package roomescape.theme.util import jakarta.persistence.EntityManager +import org.springframework.transaction.support.TransactionTemplate import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.theme.infrastructure.persistence.ThemeEntity @@ -12,13 +13,25 @@ import roomescape.util.TimeFixture import java.time.LocalDate import java.time.LocalTime -object TestThemeCreateUtil { - fun createThemeWithReservations( - entityManager: EntityManager, - name: String, - reservedCount: Int, - date: LocalDate, - ): ThemeEntity { +class TestThemeDataHelper( + val entityManager: EntityManager, + val transactionTemplate: TransactionTemplate? +) { + /** + * GET /themes/most-reserved-last-week API와 관련 Repository 테스트에 사용 + * @param name: 테마 이름 + * @param reservedCount: 이 테마가 예약된 횟수 + * @param date: reservedCount 개의 예약을 만들 때 사용할 날짜 + */ + fun createThemeWithReservations(name: String, reservedCount: Int, date: LocalDate): ThemeEntity = + if (transactionTemplate == null) { + createAndGet(name, reservedCount, date) + } else { + transactionTemplate.execute { createAndGet(name, reservedCount, date) }!! + } + + + fun createAndGet(name: String, reservedCount: Int, date: LocalDate): ThemeEntity { val themeEntity: ThemeEntity = ThemeFixture.create(name = name).also { entityManager.persist(it) } val member: MemberEntity = MemberFixture.create().also { entityManager.persist(it) } @@ -32,7 +45,7 @@ object TestThemeCreateUtil { theme = themeEntity, member = member, time = time, - status = ReservationStatus.CONFIRMED + status = ReservationStatus.entries.random() ).also { entityManager.persist(it) } } diff --git a/src/test/kotlin/roomescape/theme/web/MostReservedThemeApiTest.kt b/src/test/kotlin/roomescape/theme/web/MostReservedThemeApiTest.kt index 48911cd5..b29a4bba 100644 --- a/src/test/kotlin/roomescape/theme/web/MostReservedThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/web/MostReservedThemeApiTest.kt @@ -9,7 +9,7 @@ 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.util.TestThemeCreateUtil +import roomescape.theme.util.TestThemeDataHelper import roomescape.util.CleanerMode import roomescape.util.DatabaseCleanerExtension import java.time.LocalDate @@ -20,16 +20,16 @@ class MostReservedThemeApiTest( @LocalServerPort val port: Int, val transactionTemplate: TransactionTemplate, val entityManager: EntityManager, -) : FunSpec({ - extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_SPEC)) -}) { +) : FunSpec({ extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_SPEC)) }) { + + val helper = TestThemeDataHelper(entityManager, transactionTemplate) + init { beforeSpec { transactionTemplate.executeWithoutResult { - // 지난 7일간 예약된 테마 10개 생성 + // 7일 전 ~ 1일 전 예약된 테마 10개 생성 for (i in 1..10) { - TestThemeCreateUtil.createThemeWithReservations( - entityManager = entityManager, + helper.createThemeWithReservations( name = "테마$i", reservedCount = 1, date = LocalDate.now().minusDays(Random.nextLong(1, 7)) @@ -37,16 +37,22 @@ class MostReservedThemeApiTest( } // 8일 전 예약된 테마 1개 생성 - TestThemeCreateUtil.createThemeWithReservations( - entityManager = entityManager, + helper.createThemeWithReservations( name = "테마11", reservedCount = 1, date = LocalDate.now().minusDays(8) ) + + // 당일 예약된 테마 1개 생성 + helper.createThemeWithReservations( + name = "테마12", + reservedCount = 1, + date = LocalDate.now() + ) } } - context("지난 주 가장 많이 예약된 테마 API") { + context("GET /themes/most-reserved-last-week") { val endpoint = "/themes/most-reserved-last-week" test("count 파라미터가 없으면 10개를 반환한다") { @@ -87,8 +93,8 @@ class MostReservedThemeApiTest( } test("지난 7일 동안의 예약만 집계한다") { - // 8일 전에 예약된 테마는 집계에서 제외되어야 한다. - val count = 11 + // beforeSpec 에서 정의한 테스트 데이터 중, 8일 전 / 당일 예약 테마는 제외되어야 한다. + val count = 12 Given { port(port) param("count", count) diff --git a/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt b/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt index a6cc1dff..8fb1745a 100644 --- a/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt +++ b/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt @@ -1,42 +1,32 @@ package roomescape.theme.web 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.hamcrest.Matchers.equalTo import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import roomescape.auth.exception.AuthErrorCode +import roomescape.common.exception.CommonErrorCode import roomescape.theme.business.ThemeService import roomescape.theme.exception.ThemeErrorCode -import roomescape.theme.infrastructure.persistence.ThemeRepository +import roomescape.theme.exception.ThemeException import roomescape.util.RoomescapeApiTest import roomescape.util.ThemeFixture @WebMvcTest(ThemeController::class) -class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { - - @SpykBean +class ThemeControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() { + @MockkBean private lateinit var themeService: ThemeService - @MockkBean - private lateinit var themeRepository: ThemeRepository - init { - Given("모든 테마를 조회할 때") { + Given("GET /themes 요청을") { val endpoint = "/themes" - When("로그인 상태가 아니라면") { + When("로그인 하지 않은 사용자가 보내면") { doNotLogin() - Then("에러 응답을 받는다.") { + Then("예외 응답") { val expectedError = AuthErrorCode.MEMBER_NOT_FOUND runGetTest( mockMvc = mockMvc, @@ -52,34 +42,32 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { When("로그인 상태라면") { loginAsUser() - Then("조회에 성공한다.") { + Then("정상 응답") { every { - themeRepository.findAll() + themeService.findThemes() } returns listOf( ThemeFixture.create(id = 1, name = "theme1"), ThemeFixture.create(id = 2, name = "theme2"), ThemeFixture.create(id = 3, name = "theme3") - ) + ).toRetrieveListResponse() - val response: ThemeRetrieveListResponse = runGetTest( + runGetTest( mockMvc = mockMvc, endpoint = endpoint, ) { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) + jsonPath("$.data.themes[0].id") { value(1) } + jsonPath("$.data.themes[1].id") { value(2) } + jsonPath("$.data.themes[2].id") { value(3) } } - }.andReturn().readValue(ThemeRetrieveListResponse::class.java) - - assertSoftly(response.themes) { - it.size shouldBe 3 - it.map { m -> m.name } shouldContainAll listOf("theme1", "theme2", "theme3") } } } } - Given("테마를 추가할 때") { + Given("POST /themes 요청을") { val endpoint = "/themes" val request = ThemeCreateRequest( name = "theme1", @@ -87,9 +75,10 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { thumbnail = "http://example.com/thumbnail1.jpg" ) - When("로그인 상태가 아니라면") { + When("로그인 하지 않은 사용자가 보내면") { doNotLogin() - Then("에러 응답을 받는다.") { + + Then("예외 응답") { val expectedError = AuthErrorCode.MEMBER_NOT_FOUND runPostTest( mockMvc = mockMvc, @@ -102,9 +91,10 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { } } - When("관리자가 아닌 회원은") { + When("관리자가 아닌 사용자가 보내면") { loginAsUser() - Then("에러 응답을 받는다.") { + + Then("예외 응답") { val expectedError = AuthErrorCode.ACCESS_DENIED runPostTest( mockMvc = mockMvc, @@ -117,15 +107,17 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { } } - When("동일한 이름의 테마가 있으면") { - loginAsAdmin() + When("관리자가 보낼 때") { + beforeTest { + loginAsAdmin() + } - val expectedError = ThemeErrorCode.THEME_NAME_DUPLICATED + Then("동일한 이름의 테마가 있으면 예외 응답") { + val expectedError = ThemeErrorCode.THEME_NAME_DUPLICATED - Then("에러 응답을 받는다.") { every { - themeRepository.existsByName(request.name) - } returns true + themeService.createTheme(request) + } throws ThemeException(expectedError) runPostTest( mockMvc = mockMvc, @@ -136,80 +128,70 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { jsonPath("$.code") { value(expectedError.errorCode) } } } - } - When("값이 잘못 입력되면 400 에러를 응답한다") { - beforeTest { - loginAsAdmin() - } + When("입력 값의 형식이 잘못되면 예외 응답") { + val request = ThemeCreateRequest( + name = "theme1", + description = "description1", + thumbnail = "http://example.com/thumbnail1.jpg" + ) - val request = ThemeCreateRequest( - name = "theme1", - description = "description1", - thumbnail = "http://example.com/thumbnail1.jpg" - ) + fun runTest(request: ThemeCreateRequest) { + val expectedError = CommonErrorCode.INVALID_INPUT_VALUE - fun runTest(request: ThemeCreateRequest) { - runPostTest( - mockMvc = mockMvc, - endpoint = endpoint, - body = request, - ) { - status { isBadRequest() } + runPostTest( + mockMvc = mockMvc, + endpoint = endpoint, + body = request, + ) { + status { isEqualTo(expectedError.httpStatus.value()) } + jsonPath("$.code") { value(expectedError.errorCode) } + } + } + + Then("이름이 공백인 경우") { + val invalidRequest = request.copy(name = " ") + runTest(invalidRequest) + } + + Then("이름이 20글자를 초과하는 경우") { + val invalidRequest = request.copy(name = "a".repeat(21)) + runTest(invalidRequest) + } + + Then("설명이 공백인 경우") { + val invalidRequest = request.copy(description = " ") + runTest(invalidRequest) + } + + 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) } } - Then("이름이 공백인 경우") { - val invalidRequest = request.copy(name = " ") - runTest(invalidRequest) - } + Then("정상 응답") { + val theme = ThemeFixture.create( + id = 1, + name = request.name, + description = request.description, + thumbnail = request.thumbnail + ) - Then("이름이 20글자를 초과하는 경우") { - val invalidRequest = request.copy(name = "a".repeat(21)) - runTest(invalidRequest) - } + every { + themeService.createTheme(request) + } returns theme.toCreateResponse() - Then("설명이 공백인 경우") { - val invalidRequest = request.copy(description = " ") - runTest(invalidRequest) - } - - 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 { - themeService.createTheme(request) - } returns ThemeRetrieveResponse( - id = theme.id!!, - name = theme.name, - description = theme.description, - thumbnail = theme.thumbnail - ) - - Then("201 응답을 받는다.") { runPostTest( mockMvc = mockMvc, endpoint = endpoint, @@ -228,13 +210,14 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { } } - Given("테마를 제거할 때") { + Given("DELETE /themes/{id} 요청을") { val themeId = 1L val endpoint = "/themes/$themeId" - When("로그인 상태가 아니라면") { - doNotLogin() - Then("에러 응답을 받는다.") { + When("관리자가 아닌 사용자가 보내면 예외 응답") { + Then("로그인 하지 않은 경우") { + doNotLogin() + val expectedError = AuthErrorCode.MEMBER_NOT_FOUND runDeleteTest( mockMvc = mockMvc, @@ -244,11 +227,10 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { jsonPath("$.code", equalTo(expectedError.errorCode)) } } - } - When("관리자가 아닌 회원은") { - loginAsUser() - Then("에러 응답을 받는다.") { + Then("로그인은 하였으나 관리자가 아닌 경우") { + loginAsUser() + val expectedError = AuthErrorCode.ACCESS_DENIED runDeleteTest( mockMvc = mockMvc, @@ -260,14 +242,16 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { } } - When("이미 예약된 테마이면") { - loginAsAdmin() - val expectedError = ThemeErrorCode.THEME_ALREADY_RESERVED + When("관리자가 보낼 때") { + beforeTest { + loginAsAdmin() + } - Then("에러 응답을 받는다.") { + Then("이미 예약된 테마이면 예외 응답") { + val expectedError = ThemeErrorCode.THEME_ALREADY_RESERVED every { - themeRepository.isReservedTheme(themeId) - } returns true + themeService.deleteTheme(themeId) + } throws ThemeException(expectedError) runDeleteTest( mockMvc = mockMvc, @@ -277,20 +261,10 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { jsonPath("$.code") { value(expectedError.errorCode) } } } - } - When("정상적으로 제거되면") { - loginAsAdmin() + Then("정상 응답") { + every { themeService.deleteTheme(themeId) } returns Unit - every { - themeRepository.isReservedTheme(themeId) - } returns false - - every { - themeRepository.deleteById(themeId) - } just runs - - Then("204 응답을 받는다.") { runDeleteTest( mockMvc = mockMvc, endpoint = endpoint, diff --git a/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt b/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt index 73266aaf..95add3b7 100644 --- a/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt +++ b/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt @@ -1,38 +1,50 @@ 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.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.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("findById") { + val id = 1L + + test("정상 응답") { + every { + timeFinder.findById(id) + } returns TimeFixture.create(id = id) + + timeService.findById(id).id shouldBe id + } - context("findTimeById") { test("시간을 찾을 수 없으면 예외 응답") { - val id = 1L - - every { timeRepository.findByIdOrNull(id) } returns null + every { + timeFinder.findById(id) + } throws TimeException(TimeErrorCode.TIME_NOT_FOUND) shouldThrow { timeService.findById(id) @@ -42,22 +54,81 @@ class TimeServiceTest : FunSpec({ } } + context("findTimes") { + test("정상 응답") { + val times: List = 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 = 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 { + 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 - ) + test("정상 응답") { + 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 { timeService.createTime(request) @@ -67,14 +138,13 @@ class TimeServiceTest : FunSpec({ } } - context("removeTimeById") { - test("정상 제거 및 응답") { + 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 { timeService.deleteTime(id) @@ -84,7 +154,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 { timeService.deleteTime(id) @@ -97,9 +167,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 { timeService.deleteTime(id) diff --git a/src/test/kotlin/roomescape/time/implement/TimeFinderTest.kt b/src/test/kotlin/roomescape/time/implement/TimeFinderTest.kt new file mode 100644 index 00000000..5c7c092d --- /dev/null +++ b/src/test/kotlin/roomescape/time/implement/TimeFinderTest.kt @@ -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.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 { + 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 { + 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 = + timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId) + + assertSoftly(result) { + it shouldHaveSize 2 + it.all { time -> time.isReservable } + } + } + } +}) diff --git a/src/test/kotlin/roomescape/time/implement/TimeValidatorTest.kt b/src/test/kotlin/roomescape/time/implement/TimeValidatorTest.kt new file mode 100644 index 00000000..ff835923 --- /dev/null +++ b/src/test/kotlin/roomescape/time/implement/TimeValidatorTest.kt @@ -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 { + timeValidator.validateIsAlreadyExists(startAt) + }.also { + it.errorCode shouldBe TimeErrorCode.TIME_DUPLICATED + } + } + + test("같은 이메일을 가진 회원이 없으면 종료한다.") { + every { + timeRepository.existsByStartAt(startAt) + } returns false + + shouldNotThrow { + timeValidator.validateIsAlreadyExists(startAt) + } + } + } + + context("validateIsReserved") { + val time: TimeEntity = TimeFixture.create(startAt = LocalTime.now()) + + test("해당 시간에 예약이 있으면 예외를 던진다.") { + every { + reservationFinder.isTimeReserved(time) + } returns true + + shouldThrow { + timeValidator.validateIsReserved(time) + }.also { + it.errorCode shouldBe TimeErrorCode.TIME_ALREADY_RESERVED + } + } + + test("해당 시간에 예약이 없으면 종료한다.") { + every { + reservationFinder.isTimeReserved(time) + } returns false + + shouldNotThrow { + timeValidator.validateIsReserved(time) + } + } + } +}) diff --git a/src/test/kotlin/roomescape/time/implement/TimeWriterTest.kt b/src/test/kotlin/roomescape/time/implement/TimeWriterTest.kt new file mode 100644 index 00000000..017e7047 --- /dev/null +++ b/src/test/kotlin/roomescape/time/implement/TimeWriterTest.kt @@ -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 { + 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 { + 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) + } + } + } +}) diff --git a/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt b/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt index c5a57e44..42543d34 100644 --- a/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt +++ b/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt @@ -1,62 +1,46 @@ 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 @WebMvcTest(TimeController::class) -class TimeControllerTest( - val mockMvc: MockMvc, -) : RoomescapeApiTest() { - - @SpykBean +class TimeControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() { + @MockkBean private lateinit var timeService: TimeService - @MockkBean - private lateinit var timeRepository: TimeRepository - - @MockkBean - private lateinit var reservationRepository: ReservationRepository - init { - Given("등록된 모든 시간을 조회할 때") { + Given("GET /times 요청을") { val endpoint = "/times" - When("관리자인 경우") { + When("관리자가 보내면") { beforeTest { loginAsAdmin() } Then("정상 응답") { every { - timeRepository.findAll() + timeService.findTimes() } returns listOf( TimeFixture.create(id = 1L), TimeFixture.create(id = 2L) - ) + ).toResponse() runGetTest( mockMvc = mockMvc, @@ -72,11 +56,11 @@ class TimeControllerTest( } } - When("관리자가 아닌 경우") { + When("관리자가 보내지 않았다면") { loginAsUser() val expectedError = AuthErrorCode.ACCESS_DENIED - Then("에러 응답을 받는다.") { + Then("예외 응답") { runGetTest( mockMvc = mockMvc, endpoint = endpoint, @@ -85,17 +69,17 @@ class TimeControllerTest( }.andExpect { content { contentType(MediaType.APPLICATION_JSON) - jsonPath("$.code") { value(expectedError.errorCode) } + jsonPath("$.code") { equalTo(expectedError.errorCode) } } } } } } - Given("시간을 추가할 때") { + Given("POST /times 요청을") { val endpoint = "/times" - When("관리자인 경우") { + When("관리자가 보낼 때") { beforeTest { loginAsAdmin() } @@ -139,8 +123,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,16 +134,16 @@ class TimeControllerTest( status { isEqualTo(expectedError.httpStatus.value()) } content { contentType(MediaType.APPLICATION_JSON) - jsonPath("$.code") { value(expectedError.errorCode) } + jsonPath("$.code") { equalTo(expectedError.errorCode) } } } } } - When("관리자가 아닌 경우") { + When("관리자가 보내지 않았다면") { loginAsUser() - Then("에러 응답을 받는다.") { + Then("예외 응답") { val expectedError = AuthErrorCode.ACCESS_DENIED runPostTest( mockMvc = mockMvc, @@ -173,10 +157,10 @@ class TimeControllerTest( } } - Given("시간을 삭제할 때") { + Given("DELETE /times/{id} 요청을") { val endpoint = "/times/1" - When("관리자인 경우") { + When("관리자가 보낼 때") { beforeTest { loginAsAdmin() } @@ -197,9 +181,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 +193,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 +201,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,16 +213,16 @@ class TimeControllerTest( status { isEqualTo(expectedError.httpStatus.value()) } content { contentType(MediaType.APPLICATION_JSON) - jsonPath("$.code") { value(expectedError.errorCode) } + jsonPath("$.code") { equalTo(expectedError.errorCode) } } } } } - When("관리자가 아닌 경우") { + When("관리자가 보내지 않았다면") { loginAsUser() - Then("에러 응답을 받는다.") { + Then("예외 응답") { val expectedError = AuthErrorCode.ACCESS_DENIED runDeleteTest( mockMvc = mockMvc, @@ -253,36 +235,28 @@ class TimeControllerTest( } } - Given("날짜, 테마가 주어졌을 때") { - loginAsUser() - + Given("GET /times/search?date={date}&themeId={themeId} 요청을 ") { val date: LocalDate = LocalDate.now() val themeId = 1L - When("저장된 예약 시간이 있으면") { - val times: List = listOf( - TimeFixture.create(id = 1L, startAt = LocalTime.of(10, 0)), - TimeFixture.create(id = 2L, startAt = LocalTime.of(11, 0)) - ) + When("회원이 보낼 때") { + beforeTest { + loginAsUser() + } - 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] + 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 response = runGetTest( + val result = runGetTest( mockMvc = mockMvc, endpoint = "/times/search?date=$date&themeId=$themeId", ) { @@ -292,16 +266,47 @@ 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 } } + 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)) + } + + } } } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/util/Fixtures.kt index 526e3d16..8c74a3da 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/util/Fixtures.kt @@ -14,6 +14,7 @@ import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.AdminReservationCreateRequest import roomescape.reservation.web.ReservationCreateWithPaymentRequest import roomescape.reservation.web.WaitingCreateRequest import roomescape.theme.infrastructure.persistence.ThemeEntity @@ -22,7 +23,6 @@ import java.time.LocalDate import java.time.LocalTime import java.time.OffsetDateTime - val TsidFactory: TsidFactory = TsidFactory(0) object MemberFixture { @@ -103,6 +103,18 @@ object ReservationFixture { paymentType = paymentType ) + fun createAdminRequest( + date: LocalDate = LocalDate.now().plusWeeks(1), + themeId: Long = 1L, + timeId: Long = 1L, + memberId: Long = 1L + ): AdminReservationCreateRequest = AdminReservationCreateRequest( + date = date, + timeId = timeId, + themeId = themeId, + memberId = memberId + ) + fun createWaitingRequest( date: LocalDate = LocalDate.now().plusWeeks(1), themeId: Long = 1L, diff --git a/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt b/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt index 9a038941..aaa2af4c 100644 --- a/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt +++ b/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt @@ -11,7 +11,6 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.context.annotation.Primary import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.test.web.servlet.* @@ -22,7 +21,9 @@ import roomescape.auth.web.support.AuthInterceptor import roomescape.auth.web.support.MemberIdResolver import roomescape.common.config.JacksonConfig import roomescape.common.log.ApiLogMessageConverter -import roomescape.member.business.MemberService +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.implement.MemberFinder import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID @@ -37,14 +38,14 @@ abstract class RoomescapeApiTest : BehaviorSpec() { @SpykBean private lateinit var memberIdResolver: MemberIdResolver - @SpykBean - lateinit var memberService: MemberService + @MockkBean + private lateinit var memberRepository: MemberRepository @SpykBean lateinit var apiLogMessageConverter: ApiLogMessageConverter - @MockkBean - lateinit var memberRepository: MemberRepository + @SpykBean + lateinit var memberFinder: MemberFinder @MockkBean lateinit var jwtHandler: JwtHandler @@ -96,8 +97,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() { jwtHandler.getMemberIdFromToken(any()) } returns admin.id!! - every { memberRepository.existsById(admin.id!!) } returns true - every { memberRepository.findByIdOrNull(admin.id!!) } returns admin + every { memberFinder.findById(admin.id!!) } returns admin } fun loginAsUser() { @@ -105,8 +105,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() { jwtHandler.getMemberIdFromToken(any()) } returns user.id!! - every { memberRepository.existsById(user.id!!) } returns true - every { memberRepository.findByIdOrNull(user.id!!) } returns user + every { memberFinder.findById(user.id!!) } returns user } fun doNotLogin() { @@ -114,8 +113,9 @@ abstract class RoomescapeApiTest : BehaviorSpec() { jwtHandler.getMemberIdFromToken(any()) } throws AuthException(AuthErrorCode.INVALID_TOKEN) - every { memberRepository.existsById(NOT_LOGGED_IN_USERID) } returns false - every { memberRepository.findByIdOrNull(NOT_LOGGED_IN_USERID) } returns null + every { + memberFinder.findById(NOT_LOGGED_IN_USERID) + } throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND) } fun MvcResult.readValue(valueType: Class): T = this.response.contentAsString