Merge pull request '[#16] Reservation 도메인 코드 코틀린 마이그레이션' (#17) from refactor/#16 into main

Reviewed-on: #17
This commit is contained in:
이상진 2025-07-21 12:08:56 +00:00
commit 790c34cc3c
81 changed files with 3309 additions and 3464 deletions

View File

@ -6,7 +6,7 @@ import roomescape.auth.web.LoginCheckResponse
import roomescape.auth.web.LoginRequest
import roomescape.auth.web.TokenResponse
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Member
import roomescape.member.infrastructure.persistence.MemberEntity
@Service
class AuthService(
@ -14,7 +14,7 @@ class AuthService(
private val jwtHandler: JwtHandler
) {
fun login(request: LoginRequest): TokenResponse {
val member: Member = memberService.findMemberByEmailAndPassword(
val member: MemberEntity = memberService.findMemberByEmailAndPassword(
request.email,
request.password
)

View File

@ -10,7 +10,7 @@ import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Member
import roomescape.member.infrastructure.persistence.MemberEntity
private fun Any.isIrrelevantWith(annotationType: Class<out Annotation>): Boolean {
if (this !is HandlerMethod) {
@ -63,7 +63,7 @@ class AdminInterceptor(
return true
}
val member: Member?
val member: MemberEntity?
try {
val token: String? = request.accessTokenCookie().value

View File

@ -6,7 +6,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.infrastructure.persistence.Member
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.member.web.MembersResponse
import roomescape.member.web.toResponse
@ -22,14 +22,14 @@ class MemberService(
.toList()
)
fun findById(memberId: Long): Member = memberRepository.findByIdOrNull(memberId)
fun findById(memberId: Long): MemberEntity = memberRepository.findByIdOrNull(memberId)
?: throw RoomescapeException(
ErrorType.MEMBER_NOT_FOUND,
String.format("[memberId: %d]", memberId),
HttpStatus.BAD_REQUEST
)
fun findMemberByEmailAndPassword(email: String, password: String): Member =
fun findMemberByEmailAndPassword(email: String, password: String): MemberEntity =
memberRepository.findByEmailAndPassword(email, password)
?: throw RoomescapeException(
ErrorType.MEMBER_NOT_FOUND,

View File

@ -3,7 +3,8 @@ package roomescape.member.infrastructure.persistence
import jakarta.persistence.*
@Entity
class Member(
@Table(name = "member")
class MemberEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,

View File

@ -2,6 +2,6 @@ package roomescape.member.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface MemberRepository : JpaRepository<Member, Long> {
fun findByEmailAndPassword(email: String, password: String): Member?
interface MemberRepository : JpaRepository<MemberEntity, Long> {
fun findByEmailAndPassword(email: String, password: String): MemberEntity?
}

View File

@ -1,9 +1,9 @@
package roomescape.member.web
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.member.infrastructure.persistence.Member
import roomescape.member.infrastructure.persistence.MemberEntity
fun Member.toResponse(): MemberResponse = MemberResponse(
fun MemberEntity.toResponse(): MemberResponse = MemberResponse(
id = id!!,
name = name
)
@ -18,7 +18,7 @@ data class MemberResponse(
) {
companion object {
@JvmStatic
fun fromEntity(member: Member): MemberResponse {
fun fromEntity(member: MemberEntity): MemberResponse {
return MemberResponse(member.id!!, member.name)
}
}

View File

@ -13,7 +13,7 @@ import roomescape.payment.web.PaymentApprove
import roomescape.payment.web.PaymentCancel
import roomescape.payment.web.ReservationPaymentResponse
import roomescape.payment.web.toReservationPaymentResponse
import roomescape.reservation.domain.Reservation
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime
@Service
@ -24,7 +24,7 @@ class PaymentService(
@Transactional
fun savePayment(
paymentResponse: PaymentApprove.Response,
reservation: Reservation
reservation: ReservationEntity
): ReservationPaymentResponse = PaymentEntity(
orderId = paymentResponse.orderId,
paymentKey = paymentResponse.paymentKey,

View File

@ -1,7 +1,7 @@
package roomescape.payment.infrastructure.persistence
import jakarta.persistence.*
import roomescape.reservation.domain.Reservation
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime
@Entity
@ -22,7 +22,7 @@ class PaymentEntity(
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reservation_id", nullable = false)
var reservation: Reservation,
var reservation: ReservationEntity,
@Column(nullable = false)
var approvedAt: OffsetDateTime

View File

@ -4,7 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.reservation.dto.response.ReservationResponse
import roomescape.reservation.web.ReservationResponse
import roomescape.reservation.web.toResponse
import java.time.OffsetDateTime
class PaymentApprove {
@ -60,6 +61,6 @@ fun PaymentEntity.toReservationPaymentResponse(): ReservationPaymentResponse = R
orderId = this.orderId,
paymentKey = this.paymentKey,
totalAmount = this.totalAmount,
reservation = ReservationResponse.from(this.reservation),
reservation = this.reservation.toResponse(),
approvedAt = this.approvedAt
)

View File

@ -0,0 +1,244 @@
package roomescape.reservation.business
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService
import roomescape.reservation.infrastructure.persistence.*
import roomescape.reservation.web.*
import roomescape.theme.business.ThemeService
import java.time.LocalDate
import java.time.LocalDateTime
@Service
@Transactional
class ReservationService(
private val reservationRepository: ReservationRepository,
private val reservationTimeService: ReservationTimeService,
private val memberService: MemberService,
private val themeService: ThemeService,
) {
@Transactional(readOnly = true)
fun findAllReservations(): ReservationsResponse {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.build()
return ReservationsResponse(findAllReservationByStatus(spec))
}
@Transactional(readOnly = true)
fun findAllWaiting(): ReservationsResponse {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.waiting()
.build()
return ReservationsResponse(findAllReservationByStatus(spec))
}
private fun findAllReservationByStatus(spec: Specification<ReservationEntity>): List<ReservationResponse> {
return reservationRepository.findAll(spec).map { it.toResponse() }
}
fun removeReservationById(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId)
reservationRepository.deleteById(reservationId)
}
fun addReservation(request: ReservationRequest, memberId: Long): ReservationEntity {
validateIsReservationExist(request.themeId, request.timeId, request.date)
return getReservationForSave(
request.timeId,
request.themeId,
request.date,
memberId,
ReservationStatus.CONFIRMED
).also {
reservationRepository.save(it)
}
}
fun addReservationByAdmin(request: AdminReservationRequest): ReservationResponse {
validateIsReservationExist(request.themeId, request.timeId, request.date)
return addReservationWithoutPayment(
request.themeId,
request.timeId,
request.date,
request.memberId,
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
}
fun addWaiting(request: WaitingRequest, memberId: Long): ReservationResponse {
validateMemberAlreadyReserve(request.themeId, request.timeId, request.date, memberId)
return addReservationWithoutPayment(
request.themeId,
request.timeId,
request.date,
memberId,
ReservationStatus.WAITING
)
}
private fun addReservationWithoutPayment(
themeId: Long,
timeId: Long,
date: LocalDate,
memberId: Long,
status: ReservationStatus
): ReservationResponse = getReservationForSave(timeId, themeId, date, memberId, status)
.also {
reservationRepository.save(it)
}.toResponse()
private fun validateMemberAlreadyReserve(themeId: Long?, timeId: Long?, date: LocalDate?, memberId: Long?) {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameMemberId(memberId)
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
throw RoomescapeException(ErrorType.HAS_RESERVATION_OR_WAITING, HttpStatus.BAD_REQUEST)
}
}
private fun validateIsReservationExist(themeId: Long, timeId: Long, date: LocalDate) {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
throw RoomescapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT)
}
}
private fun validateDateAndTime(
requestDate: LocalDate,
requestReservationTime: ReservationTimeEntity
) {
val now = LocalDateTime.now()
val request = LocalDateTime.of(requestDate, requestReservationTime.startAt)
if (request.isBefore(now)) {
throw RoomescapeException(
ErrorType.RESERVATION_PERIOD_IN_PAST,
"[now: $now | request: $request]",
HttpStatus.BAD_REQUEST
)
}
}
private fun getReservationForSave(
timeId: Long,
themeId: Long,
date: LocalDate,
memberId: Long,
status: ReservationStatus
): ReservationEntity {
val time = reservationTimeService.findTimeById(timeId)
val theme = themeService.findThemeById(themeId)
val member = memberService.findById(memberId)
validateDateAndTime(date, time)
return ReservationEntity(
date = date,
reservationTime = time,
theme = theme,
member = member,
reservationStatus = status
)
}
@Transactional(readOnly = true)
fun findFilteredReservations(
themeId: Long?,
memberId: Long?,
dateFrom: LocalDate?,
dateTo: LocalDate?
): ReservationsResponse {
validateDateForSearch(dateFrom, dateTo)
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.sameThemeId(themeId)
.sameMemberId(memberId)
.dateStartFrom(dateFrom)
.dateEndAt(dateTo)
.build()
return ReservationsResponse(findAllReservationByStatus(spec))
}
private fun validateDateForSearch(startFrom: LocalDate?, endAt: LocalDate?) {
if (startFrom == null || endAt == null) {
return
}
if (startFrom.isAfter(endAt)) {
throw RoomescapeException(
ErrorType.INVALID_DATE_RANGE,
"[startFrom: $startFrom, endAt: $endAt", HttpStatus.BAD_REQUEST
)
}
}
@Transactional(readOnly = true)
fun findMemberReservations(memberId: Long): MyReservationsResponse {
return MyReservationsResponse(reservationRepository.findMyReservations(memberId))
}
fun approveWaiting(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId)
if (reservationRepository.isExistConfirmedReservation(reservationId)) {
throw RoomescapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT)
}
reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
}
fun cancelWaiting(reservationId: Long, memberId: Long) {
reservationRepository.findByIdOrNull(reservationId)?.takeIf {
it.isWaiting() && it.isSameMember(memberId)
}?.let {
reservationRepository.delete(it)
} ?: throw throwReservationNotFound(reservationId)
}
fun denyWaiting(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId)
reservationRepository.findByIdOrNull(reservationId)?.takeIf {
it.isWaiting()
}?.let {
reservationRepository.delete(it)
} ?: throw throwReservationNotFound(reservationId)
}
private fun validateIsMemberAdmin(memberId: Long) {
memberService.findById(memberId).takeIf {
it.isAdmin()
} ?: throw RoomescapeException(
ErrorType.PERMISSION_DOES_NOT_EXIST,
"[memberId: $memberId]",
HttpStatus.FORBIDDEN
)
}
private fun throwReservationNotFound(reservationId: Long?): RoomescapeException {
return RoomescapeException(
ErrorType.RESERVATION_NOT_FOUND,
"[reservationId: $reservationId]",
HttpStatus.NOT_FOUND
)
}
}

View File

@ -0,0 +1,74 @@
package roomescape.reservation.business
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationTimeEntity
import roomescape.reservation.infrastructure.persistence.ReservationTimeRepository
import roomescape.reservation.web.*
import java.time.LocalDate
import java.time.LocalTime
@Service
class ReservationTimeService(
private val reservationTimeRepository: ReservationTimeRepository,
private val reservationRepository: ReservationRepository
) {
@Transactional(readOnly = true)
fun findTimeById(id: Long): ReservationTimeEntity = reservationTimeRepository.findByIdOrNull(id)
?: throw RoomescapeException(
ErrorType.RESERVATION_TIME_NOT_FOUND,
"[reservationTimeId: $id]",
HttpStatus.BAD_REQUEST
)
@Transactional(readOnly = true)
fun findAllTimes(): ReservationTimesResponse = reservationTimeRepository.findAll()
.toResponses()
@Transactional
fun addTime(reservationTimeRequest: ReservationTimeRequest): ReservationTimeResponse {
val startAt: LocalTime = reservationTimeRequest.startAt
if (reservationTimeRepository.existsByStartAt(startAt)) {
throw RoomescapeException(
ErrorType.TIME_DUPLICATED, "[startAt: $startAt]", HttpStatus.CONFLICT
)
}
return ReservationTimeEntity(startAt = startAt)
.also { reservationTimeRepository.save(it) }
.toResponse()
}
@Transactional
fun removeTimeById(id: Long) {
val reservationTime: ReservationTimeEntity = findTimeById(id)
reservationRepository.findByReservationTime(reservationTime)
.also {
if (it.isNotEmpty()) {
throw RoomescapeException(
ErrorType.TIME_IS_USED_CONFLICT, "[timeId: $id]", HttpStatus.CONFLICT
)
}
reservationTimeRepository.deleteById(id)
}
}
@Transactional(readOnly = true)
fun findAllAvailableTimesByDateAndTheme(date: LocalDate, themeId: Long): ReservationTimeInfosResponse {
val allTimes = reservationTimeRepository.findAll()
val reservations: List<ReservationEntity> = reservationRepository.findByDateAndThemeId(date, themeId)
return ReservationTimeInfosResponse(allTimes.map { time ->
val alreadyBooked: Boolean = reservations.any { reservation -> reservation.reservationTime.id == time.id }
time.toInfoResponse(alreadyBooked)
})
}
}

View File

@ -0,0 +1,58 @@
package roomescape.reservation.business
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.payment.business.PaymentService
import roomescape.payment.web.PaymentApprove
import roomescape.payment.web.PaymentCancel
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.web.ReservationRequest
import roomescape.reservation.web.ReservationResponse
import java.time.OffsetDateTime
@Service
@Transactional
class ReservationWithPaymentService(
private val reservationService: ReservationService,
private val paymentService: PaymentService
) {
fun addReservationWithPayment(
request: ReservationRequest,
paymentInfo: PaymentApprove.Response,
memberId: Long
): ReservationResponse {
val reservation: ReservationEntity = reservationService.addReservation(request, memberId)
return paymentService.savePayment(paymentInfo, reservation)
.reservation
}
fun saveCanceledPayment(
cancelInfo: PaymentCancel.Response,
approvedAt: OffsetDateTime,
paymentKey: String
) {
paymentService.saveCanceledPayment(cancelInfo, approvedAt, paymentKey)
}
fun removeReservationWithPayment(
reservationId: Long,
memberId: Long
): PaymentCancel.Request {
val paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservationId)
reservationService.removeReservationById(reservationId, memberId)
return paymentCancelRequest
}
@Transactional(readOnly = true)
fun isNotPaidReservation(reservationId: Long): Boolean = !paymentService.isReservationPaid(reservationId)
fun updateCanceledTime(
paymentKey: String,
canceledAt: OffsetDateTime
) {
paymentService.updateCanceledTime(paymentKey, canceledAt)
}
}

View File

@ -1,271 +0,0 @@
package roomescape.reservation.controller;
import java.time.LocalDate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import roomescape.auth.web.support.Admin;
import roomescape.auth.web.support.LoginRequired;
import roomescape.auth.web.support.MemberId;
import roomescape.common.dto.response.RoomescapeApiResponse;
import roomescape.common.dto.response.RoomescapeErrorResponse;
import roomescape.common.exception.RoomescapeException;
import roomescape.payment.infrastructure.client.TossPaymentClient;
import roomescape.payment.web.PaymentApprove;
import roomescape.payment.web.PaymentCancel;
import roomescape.reservation.dto.request.AdminReservationRequest;
import roomescape.reservation.dto.request.ReservationRequest;
import roomescape.reservation.dto.request.WaitingRequest;
import roomescape.reservation.dto.response.MyReservationsResponse;
import roomescape.reservation.dto.response.ReservationResponse;
import roomescape.reservation.dto.response.ReservationsResponse;
import roomescape.reservation.service.ReservationService;
import roomescape.reservation.service.ReservationWithPaymentService;
@RestController
@Tag(name = "3. 예약 API", description = "예약 및 대기 정보를 추가 / 조회 / 삭제할 때 사용합니다.")
public class ReservationController {
private final ReservationWithPaymentService reservationWithPaymentService;
private final ReservationService reservationService;
private final TossPaymentClient paymentClient;
public ReservationController(ReservationWithPaymentService reservationWithPaymentService,
ReservationService reservationService, TossPaymentClient paymentClient) {
this.reservationWithPaymentService = reservationWithPaymentService;
this.reservationService = reservationService;
this.paymentClient = paymentClient;
}
@Admin
@GetMapping("/reservations")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "모든 예약 정보 조회", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
})
public RoomescapeApiResponse<ReservationsResponse> getAllReservations() {
return RoomescapeApiResponse.success(reservationService.findAllReservations());
}
@LoginRequired
@GetMapping("/reservations-mine")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "자신의 예약 및 대기 조회", tags = "로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
})
public RoomescapeApiResponse<MyReservationsResponse> getMemberReservations(
@MemberId @Parameter(hidden = true) Long memberId) {
return RoomescapeApiResponse.success(reservationService.findMemberReservations(memberId));
}
@Admin
@GetMapping("/reservations/search")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "관리자의 예약 검색", description = "특정 조건에 해당되는 예약 검색", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "400", description = "날짜 범위를 지정할 때, 종료 날짜는 시작 날짜 이전일 수 없습니다.",
content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class)))
})
public RoomescapeApiResponse<ReservationsResponse> getReservationBySearching(
@RequestParam(required = false) @Parameter(description = "테마 ID") Long themeId,
@RequestParam(required = false) @Parameter(description = "회원 ID") Long memberId,
@RequestParam(required = false) @Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요", example = "2024-06-10") LocalDate dateFrom,
@RequestParam(required = false) @Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요", example = "2024-06-10") LocalDate dateTo
) {
return RoomescapeApiResponse.success(
reservationService.findFilteredReservations(themeId, memberId, dateFrom, dateTo));
}
@Admin
@DeleteMapping("/reservations/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "관리자의 예약 취소", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "성공"),
@ApiResponse(responseCode = "404", description = "예약 또는 결제 정보를 찾을 수 없습니다.",
content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class))),
})
public RoomescapeApiResponse<Void> removeReservation(
@MemberId @Parameter(hidden = true) Long memberId,
@NotNull(message = "reservationId는 null일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId
) {
if (reservationWithPaymentService.isNotPaidReservation(reservationId)) {
reservationService.removeReservationById(reservationId, memberId);
return RoomescapeApiResponse.success();
}
PaymentCancel.Request paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment(
reservationId, memberId);
PaymentCancel.Response paymentCancelResponse = paymentClient.cancelPayment(paymentCancelRequest);
reservationWithPaymentService.updateCanceledTime(paymentCancelRequest.paymentKey,
paymentCancelResponse.canceledAt);
return RoomescapeApiResponse.success();
}
@LoginRequired
@PostMapping("/reservations")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "예약 추가", tags = "로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true,
headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1")))
})
public RoomescapeApiResponse<ReservationResponse> saveReservation(
@Valid @RequestBody ReservationRequest reservationRequest,
@MemberId @Parameter(hidden = true) Long memberId,
HttpServletResponse response
) {
PaymentApprove.Request paymentRequest = reservationRequest.getPaymentRequest();
PaymentApprove.Response paymentResponse = paymentClient.confirmPayment(paymentRequest);
try {
ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment(
reservationRequest, paymentResponse, memberId);
return getCreatedReservationResponse(reservationResponse, response);
} catch (RoomescapeException e) {
PaymentCancel.Request cancelRequest = new PaymentCancel.Request(paymentRequest.paymentKey,
paymentRequest.amount, e.getMessage());
PaymentCancel.Response paymentCancelResponse = paymentClient.cancelPayment(cancelRequest);
reservationWithPaymentService.saveCanceledPayment(paymentCancelResponse, paymentResponse.approvedAt,
paymentRequest.paymentKey);
throw e;
}
}
@Admin
@PostMapping("/reservations/admin")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "관리자 예약 추가", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true,
headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1"))),
@ApiResponse(responseCode = "409", description = "예약이 이미 존재합니다.", content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class)))
})
public RoomescapeApiResponse<ReservationResponse> saveReservationByAdmin(
@Valid @RequestBody AdminReservationRequest adminReservationRequest,
HttpServletResponse response
) {
ReservationResponse reservationResponse = reservationService.addReservationByAdmin(adminReservationRequest);
return getCreatedReservationResponse(reservationResponse, response);
}
@Admin
@GetMapping("/reservations/waiting")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "모든 예약 대기 조회", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
})
public RoomescapeApiResponse<ReservationsResponse> getAllWaiting() {
return RoomescapeApiResponse.success(reservationService.findAllWaiting());
}
@LoginRequired
@PostMapping("/reservations/waiting")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "예약 대기 신청", tags = "로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true,
headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1")))
})
public RoomescapeApiResponse<ReservationResponse> saveWaiting(
@Valid @RequestBody WaitingRequest waitingRequest,
@MemberId @Parameter(hidden = true) Long memberId,
HttpServletResponse response
) {
ReservationResponse reservationResponse = reservationService.addWaiting(waitingRequest, memberId);
return getCreatedReservationResponse(reservationResponse, response);
}
@LoginRequired
@DeleteMapping("/reservations/waiting/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "예약 대기 취소", tags = "로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "성공"),
@ApiResponse(responseCode = "404", description = "회원의 예약 대기 정보를 찾을 수 없습니다.",
content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class)))
})
public RoomescapeApiResponse<Void> deleteWaiting(
@MemberId @Parameter(hidden = true) Long memberId,
@NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId
) {
reservationService.cancelWaiting(reservationId, memberId);
return RoomescapeApiResponse.success();
}
@Admin
@PostMapping("/reservations/waiting/{id}/approve")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "대기 중인 예약 승인", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "404", description = "예약 대기 정보를 찾을 수 없습니다.",
content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class))),
@ApiResponse(responseCode = "409", description = "확정된 예약이 존재하여 대기 중인 예약을 승인할 수 없습니다.",
content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class)))
})
public RoomescapeApiResponse<Void> approveWaiting(
@MemberId @Parameter(hidden = true) Long memberId,
@NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId
) {
reservationService.approveWaiting(reservationId, memberId);
return RoomescapeApiResponse.success();
}
@Admin
@PostMapping("/reservations/waiting/{id}/deny")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "대기 중인 예약 거절", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "대기 중인 예약 거절 성공"),
@ApiResponse(responseCode = "404", description = "예약 대기 정보를 찾을 수 없습니다.",
content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class)))
})
public RoomescapeApiResponse<Void> denyWaiting(
@MemberId @Parameter(hidden = true) Long memberId,
@NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId
) {
reservationService.denyWaiting(reservationId, memberId);
return RoomescapeApiResponse.success();
}
private RoomescapeApiResponse<ReservationResponse> getCreatedReservationResponse(
ReservationResponse reservationResponse,
HttpServletResponse response
) {
response.setHeader(HttpHeaders.LOCATION, "/reservations/" + reservationResponse.id());
return RoomescapeApiResponse.success(reservationResponse);
}
}

View File

@ -1,112 +0,0 @@
package roomescape.reservation.controller;
import java.time.LocalDate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import roomescape.auth.web.support.Admin;
import roomescape.auth.web.support.LoginRequired;
import roomescape.common.dto.response.RoomescapeApiResponse;
import roomescape.common.dto.response.RoomescapeErrorResponse;
import roomescape.reservation.dto.request.ReservationTimeRequest;
import roomescape.reservation.dto.response.ReservationTimeInfosResponse;
import roomescape.reservation.dto.response.ReservationTimeResponse;
import roomescape.reservation.dto.response.ReservationTimesResponse;
import roomescape.reservation.service.ReservationTimeService;
@RestController
@Tag(name = "4. 예약 시간 API", description = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.")
public class ReservationTimeController {
private final ReservationTimeService reservationTimeService;
public ReservationTimeController(ReservationTimeService reservationTimeService) {
this.reservationTimeService = reservationTimeService;
}
@Admin
@GetMapping("/times")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "모든 시간 조회", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
})
public RoomescapeApiResponse<ReservationTimesResponse> getAllTimes() {
return RoomescapeApiResponse.success(reservationTimeService.findAllTimes());
}
@Admin
@PostMapping("/times")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "시간 추가", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "409", description = "같은 시간을 추가할 수 없습니다.",
content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class)))
})
public RoomescapeApiResponse<ReservationTimeResponse> saveTime(
@Valid @RequestBody ReservationTimeRequest reservationTimeRequest,
HttpServletResponse response
) {
ReservationTimeResponse reservationTimeResponse = reservationTimeService.addTime(reservationTimeRequest);
response.setHeader(HttpHeaders.LOCATION, "/times/" + reservationTimeResponse.id());
return RoomescapeApiResponse.success(reservationTimeResponse);
}
@Admin
@DeleteMapping("/times/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "시간 삭제", tags = "관리자 로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "409", description = "예약된 시간은 삭제할 수 없습니다.",
content = @Content(schema = @Schema(implementation = RoomescapeErrorResponse.class)))
})
public RoomescapeApiResponse<Void> removeTime(
@NotNull(message = "timeId는 null 또는 공백일 수 없습니다.") @PathVariable @Parameter(description = "삭제하고자 하는 시간의 ID값") Long id
) {
reservationTimeService.removeTimeById(id);
return RoomescapeApiResponse.success();
}
@LoginRequired
@GetMapping("/times/filter")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "예약 가능 여부를 포함한 모든 시간 조회", tags = "로그인이 필요한 API")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
})
public RoomescapeApiResponse<ReservationTimeInfosResponse> findAllAvailableReservationTimes(
@NotNull(message = "날짜는 null일 수 없습니다.")
@RequestParam
@Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요.", example = "2024-06-10")
LocalDate date,
@NotNull(message = "themeId는 null일 수 없습니다.")
@RequestParam
@Parameter(description = "조회할 테마의 ID를 입력해주세요.", example = "1")
Long themeId
) {
return RoomescapeApiResponse.success(reservationTimeService.findAllAvailableTimesByDateAndTheme(date, themeId));
}
}

View File

@ -0,0 +1,138 @@
package roomescape.reservation.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.headers.Header
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.*
import java.time.LocalDate
@Tag(name = "3. 예약 API", description = "예약 및 대기 정보를 추가 / 조회 / 삭제할 때 사용합니다.")
interface ReservationAPI {
@Admin
@Operation(summary = "모든 예약 정보 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun getAllReservations(): ResponseEntity<CommonApiResponse<ReservationsResponse>>
@LoginRequired
@Operation(summary = "자신의 예약 및 대기 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun getMemberReservations(
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<MyReservationsResponse>>
@Admin
@Operation(summary = "관리자의 예약 검색", description = "특정 조건에 해당되는 예약 검색", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
)
fun getReservationBySearching(
@RequestParam(required = false) themeId: Long?,
@RequestParam(required = false) memberId: Long?,
@RequestParam(required = false) dateFrom: LocalDate?,
@RequestParam(required = false) dateTo: LocalDate?
): ResponseEntity<CommonApiResponse<ReservationsResponse>>
@Admin
@Operation(summary = "관리자의 예약 취소", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "성공"),
)
fun removeReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired
@Operation(summary = "예약 추가", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "201",
description = "성공",
useReturnTypeSchema = true,
headers = [Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = Schema(example = "/reservations/1"))]
)
)
fun saveReservation(
@Valid @RequestBody reservationRequest: ReservationRequest,
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationResponse>>
@Admin
@Operation(summary = "관리자 예약 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "201",
description = "성공",
useReturnTypeSchema = true,
headers = [Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = Schema(example = "/reservations/1"))],
)
)
fun saveReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationRequest,
): ResponseEntity<CommonApiResponse<ReservationResponse>>
@Admin
@Operation(summary = "모든 예약 대기 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun getAllWaiting(): ResponseEntity<CommonApiResponse<ReservationsResponse>>
@LoginRequired
@Operation(summary = "예약 대기 신청", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "201",
description = "성공",
useReturnTypeSchema = true,
headers = [Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = Schema(example = "/reservations/1"))]
)
)
fun saveWaiting(
@Valid @RequestBody waitingRequest: WaitingRequest,
@MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationResponse>>
@LoginRequired
@Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "성공"),
)
fun deleteWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>>
@Admin
@Operation(summary = "대기 중인 예약 승인", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun approveWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>>
@Admin
@Operation(summary = "대기 중인 예약 거절", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "대기 중인 예약 거절 성공"),
)
fun denyWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>>
}

View File

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

View File

@ -1,127 +0,0 @@
package roomescape.reservation.domain;
import java.time.LocalDate;
import org.springframework.http.HttpStatus;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import roomescape.common.exception.ErrorType;
import roomescape.common.exception.RoomescapeException;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
@Entity
public class Reservation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDate date;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "time_id", nullable = false)
private ReservationTime reservationTime;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "theme_id", nullable = false)
private ThemeEntity theme;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
@Enumerated(value = EnumType.STRING)
private ReservationStatus reservationStatus;
protected Reservation() {
}
public Reservation(
LocalDate date,
ReservationTime reservationTime,
ThemeEntity theme,
Member member,
ReservationStatus status
) {
this(null, date, reservationTime, theme, member, status);
}
public Reservation(
Long id,
LocalDate date,
ReservationTime reservationTime,
ThemeEntity theme,
Member member,
ReservationStatus status
) {
validateIsNull(date, reservationTime, theme, member, status);
this.id = id;
this.date = date;
this.reservationTime = reservationTime;
this.theme = theme;
this.member = member;
this.reservationStatus = status;
}
private void validateIsNull(LocalDate date, ReservationTime reservationTime, ThemeEntity theme, Member member,
ReservationStatus reservationStatus) {
if (date == null || reservationTime == null || theme == null || member == null || reservationStatus == null) {
throw new RoomescapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[values: %s]", this),
HttpStatus.BAD_REQUEST);
}
}
public Long getMemberId() {
return member.getId();
}
public Long getId() {
return id;
}
public LocalDate getDate() {
return date;
}
public ReservationTime getReservationTime() {
return reservationTime;
}
public ThemeEntity getTheme() {
return theme;
}
public Member getMember() {
return member;
}
public ReservationStatus getReservationStatus() {
return reservationStatus;
}
@JsonIgnore
public boolean isSameDateAndTime(LocalDate date, ReservationTime time) {
return this.date.equals(date) && time.getStartAt().equals(this.reservationTime.getStartAt());
}
@JsonIgnore
public boolean isWaiting() {
return reservationStatus == ReservationStatus.WAITING;
}
@JsonIgnore
public boolean isSameMember(Long memberId) {
return getMemberId().equals(memberId);
}
}

View File

@ -1,13 +0,0 @@
package roomescape.reservation.domain;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "예약 상태를 나타냅니다.", allowableValues = {"CONFIRMED", "CONFIRMED_PAYMENT_REQUIRED", "WAITING"})
public enum ReservationStatus {
@Schema(description = "결제가 완료된 예약")
CONFIRMED,
@Schema(description = "결제가 필요한 예약")
CONFIRMED_PAYMENT_REQUIRED,
@Schema(description = "대기 중인 예약")
WAITING;
}

View File

@ -1,59 +0,0 @@
package roomescape.reservation.domain;
import java.time.LocalTime;
import org.springframework.http.HttpStatus;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import roomescape.common.exception.ErrorType;
import roomescape.common.exception.RoomescapeException;
@Entity
public class ReservationTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalTime startAt;
protected ReservationTime() {
}
public ReservationTime(final LocalTime startAt) {
this(null, startAt);
}
public ReservationTime(final Long id, final LocalTime startAt) {
this.id = id;
this.startAt = startAt;
validateNull();
}
private void validateNull() {
if (startAt == null) {
throw new RoomescapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[values: %s]", this),
HttpStatus.BAD_REQUEST);
}
}
public Long getId() {
return id;
}
public LocalTime getStartAt() {
return startAt;
}
@Override
public String toString() {
return "ReservationTime{" +
"id=" + id +
", startAt=" + startAt +
'}';
}
}

View File

@ -1,62 +0,0 @@
package roomescape.reservation.domain.repository;
import java.util.List;
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.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.dto.response.MyReservationResponse;
public interface ReservationRepository extends JpaRepository<Reservation, Long>, JpaSpecificationExecutor<Reservation> {
List<Reservation> findByReservationTime(ReservationTime reservationTime);
List<Reservation> findByThemeId(Long themeId);
@Modifying
@Query("""
UPDATE Reservation r
SET r.reservationStatus = :status
WHERE r.id = :id
""")
int updateStatusByReservationId(@Param(value = "id") Long reservationId,
@Param(value = "status") ReservationStatus statusForChange);
@Query("""
SELECT EXISTS (
SELECT 1 FROM Reservation r
WHERE r.theme.id = r2.theme.id
AND r.reservationTime.id = r2.reservationTime.id
AND r.date = r2.date
AND r.reservationStatus != 'WAITING'
)
FROM Reservation r2
WHERE r2.id = :id
""")
boolean isExistConfirmedReservation(@Param("id") Long reservationId);
@Query("""
SELECT new roomescape.reservation.dto.response.MyReservationResponse(
r.id,
t.name,
r.date,
r.reservationTime.startAt,
r.reservationStatus,
(SELECT COUNT (r2) FROM Reservation r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.reservationTime = r.reservationTime AND r2.id < r.id),
p.paymentKey,
p.totalAmount
)
FROM Reservation r
JOIN r.theme t
LEFT JOIN PaymentEntity p
ON p.reservation = r
WHERE r.member.id = :memberId
""")
List<MyReservationResponse> findMyReservations(Long memberId);
}

View File

@ -1,87 +0,0 @@
package roomescape.reservation.domain.repository;
import java.time.LocalDate;
import org.springframework.data.jpa.domain.Specification;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
public class ReservationSearchSpecification {
private Specification<Reservation> spec;
public ReservationSearchSpecification() {
this.spec = Specification.where(null);
}
public ReservationSearchSpecification sameThemeId(Long themeId) {
if (themeId != null) {
this.spec = this.spec.and(
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("theme").get("id"), themeId));
}
return this;
}
public ReservationSearchSpecification sameMemberId(Long memberId) {
if (memberId != null) {
this.spec = this.spec.and(
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("member").get("id"), memberId));
}
return this;
}
public ReservationSearchSpecification sameTimeId(Long timeId) {
if (timeId != null) {
this.spec = this.spec.and(
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("reservationTime").get("id"),
timeId));
}
return this;
}
public ReservationSearchSpecification sameDate(LocalDate date) {
if (date != null) {
this.spec = this.spec.and(
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("date"), date));
}
return this;
}
public ReservationSearchSpecification confirmed() {
this.spec = this.spec.and(
(root, query, criteriaBuilder) -> criteriaBuilder.or(
criteriaBuilder.equal(root.get("reservationStatus"), ReservationStatus.CONFIRMED),
criteriaBuilder.equal(root.get("reservationStatus"),
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
));
return this;
}
public ReservationSearchSpecification waiting() {
this.spec = this.spec.and(
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("reservationStatus"),
ReservationStatus.WAITING));
return this;
}
public ReservationSearchSpecification dateStartFrom(LocalDate dateFrom) {
if (dateFrom != null) {
this.spec = this.spec.and(
(root, query, criteriaBuilder) -> criteriaBuilder.greaterThanOrEqualTo(root.get("date"), dateFrom));
}
return this;
}
public ReservationSearchSpecification dateEndAt(LocalDate toDate) {
if (toDate != null) {
this.spec = this.spec.and(
(root, query, criteriaBuilder) -> criteriaBuilder.lessThanOrEqualTo(root.get("date"), toDate));
}
return this;
}
public Specification<Reservation> build() {
return this.spec;
}
}

View File

@ -1,13 +0,0 @@
package roomescape.reservation.domain.repository;
import java.time.LocalTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import roomescape.reservation.domain.ReservationTime;
public interface ReservationTimeRepository extends JpaRepository<ReservationTime, Long> {
List<ReservationTime> findByStartAt(LocalTime startAt);
}

View File

@ -1,18 +0,0 @@
package roomescape.reservation.dto.request;
import java.time.LocalDate;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "관리자 예약 저장 요청", description = "관리자의 예약 저장 요청시 사용됩니다.")
public record AdminReservationRequest(
@Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
LocalDate date,
@Schema(description = "예약 시간 ID.", example = "1")
Long timeId,
@Schema(description = "테마 ID", example = "1")
Long themeId,
@Schema(description = "회원 ID", example = "1")
Long memberId
) {
}

View File

@ -1,36 +0,0 @@
package roomescape.reservation.dto.request;
import java.time.LocalDate;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import roomescape.payment.web.PaymentApprove;
@Schema(name = "회원의 예약 저장 요청", description = "회원의 예약 요청시 사용됩니다.")
public record ReservationRequest(
@NotNull(message = "예약 날짜는 null일 수 없습니다.")
@Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
LocalDate date,
@NotNull(message = "예약 요청의 timeId는 null일 수 없습니다.")
@Schema(description = "예약 시간 ID.", example = "1")
Long timeId,
@NotNull(message = "예약 요청의 themeId는 null일 수 없습니다.")
@Schema(description = "테마 ID", example = "1")
Long themeId,
@Schema(description = "결제 위젯을 통해 받은 결제 키")
String paymentKey,
@Schema(description = "결제 위젯을 통해 받은 주문번호.")
String orderId,
@Schema(description = "결제 위젯을 통해 받은 결제 금액")
Long amount,
@Schema(description = "결제 타입", example = "NORMAL")
String paymentType
) {
@JsonIgnore
public PaymentApprove.Request getPaymentRequest() {
return new PaymentApprove.Request(paymentKey, orderId, amount, paymentType);
}
}

View File

@ -1,31 +0,0 @@
package roomescape.reservation.dto.request;
import java.time.LocalTime;
import org.springframework.http.HttpStatus;
import io.micrometer.common.util.StringUtils;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import roomescape.common.exception.ErrorType;
import roomescape.common.exception.RoomescapeException;
import roomescape.reservation.domain.ReservationTime;
@Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.")
public record ReservationTimeRequest(
@NotNull(message = "예약 시간은 null일 수 없습니다.")
@Schema(description = "예약 시간. HH:mm 형식으로 입력해야 합니다.", type = "string", example = "09:00")
LocalTime startAt
) {
public ReservationTimeRequest {
if (StringUtils.isBlank(startAt.toString())) {
throw new RoomescapeException(ErrorType.REQUEST_DATA_BLANK,
String.format("[values: %s]", this), HttpStatus.BAD_REQUEST);
}
}
public ReservationTime toTime() {
return new ReservationTime(this.startAt);
}
}

View File

@ -1,20 +0,0 @@
package roomescape.reservation.dto.request;
import java.time.LocalDate;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(name = "예약 대기 저장 요청", description = "회원의 예약 대기 요청시 사용됩니다.")
public record WaitingRequest(
@NotNull(message = "예약 날짜는 null일 수 없습니다.")
@Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
LocalDate date,
@NotNull(message = "예약 요청의 timeId는 null일 수 없습니다.")
@Schema(description = "예약 시간 ID", example = "1")
Long timeId,
@NotNull(message = "예약 요청의 themeId는 null일 수 없습니다.")
@Schema(description = "테마 ID", example = "1")
Long themeId
) {
}

View File

@ -1,33 +0,0 @@
package roomescape.reservation.dto.response;
import java.time.LocalDate;
import java.time.LocalTime;
import io.swagger.v3.oas.annotations.media.Schema;
import roomescape.reservation.domain.ReservationStatus;
@Schema(name = "회원의 예약 및 대기 응답", description = "회원의 예약 및 대기 정보 응답시 사용됩니다.")
public record MyReservationResponse(
@Schema(description = "예약 번호. 예약을 식별할 때 사용합니다.")
Long id,
@Schema(description = "테마 이름")
String themeName,
@Schema(description = "예약 날짜", type = "string", example = "2022-12-31")
LocalDate date,
@Schema(description = "예약 시간", type = "string", example = "09:00")
LocalTime time,
@Schema(description = "예약 상태", type = "string")
ReservationStatus status,
@Schema(description = "예약 대기 상태일 때의 대기 순번. 확정된 예약은 0의 값을 가집니다.")
Long rank,
@Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.")
String paymentKey,
@Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.")
Long amount
) {
public MyReservationResponse(Long id, String themeName, LocalDate date, LocalTime time, ReservationStatus status,
Integer rank, String paymentKey, Long amount) {
this(id, themeName, date, time, status, rank.longValue(), paymentKey, amount);
}
}

View File

@ -1,11 +0,0 @@
package roomescape.reservation.dto.response;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "회원의 예약 및 대기 목록 조회 응답", description = "회원의 예약 및 대기 목록 조회 응답시 사용됩니다.")
public record MyReservationsResponse(
@Schema(description = "현재 로그인한 회원의 예약 및 대기 목록") List<MyReservationResponse> myReservationResponses
) {
}

View File

@ -1,42 +0,0 @@
package roomescape.reservation.dto.response;
import java.time.LocalDate;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import roomescape.member.web.MemberResponse;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.theme.web.ThemeResponse;
@Schema(name = "예약 정보", description = "예약 저장 및 조회 응답에 사용됩니다.")
public record ReservationResponse(
@Schema(description = "예약 번호. 예약을 식별할 때 사용합니다.")
Long id,
@Schema(description = "예약 날짜", type = "string", example = "2022-12-31")
LocalDate date,
@JsonProperty("member")
@Schema(description = "예약한 회원 정보")
MemberResponse member,
@JsonProperty("time")
@Schema(description = "예약 시간 정보")
ReservationTimeResponse time,
@JsonProperty("theme")
@Schema(description = "예약한 테마 정보")
ThemeResponse theme,
@Schema(description = "예약 상태", type = "string")
ReservationStatus status
) {
public static ReservationResponse from(Reservation reservation) {
return new ReservationResponse(
reservation.getId(),
reservation.getDate(),
MemberResponse.fromEntity(reservation.getMember()),
ReservationTimeResponse.from(reservation.getReservationTime()),
ThemeResponse.from(reservation.getTheme()),
reservation.getReservationStatus()
);
}
}

View File

@ -1,16 +0,0 @@
package roomescape.reservation.dto.response;
import java.time.LocalTime;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "특정 테마, 날짜에 대한 시간 정보 응답", description = "특정 날짜와 테마에 대해, 예약 가능 여부를 포함한 시간 정보를 저장합니다.")
public record ReservationTimeInfoResponse(
@Schema(description = "예약 시간 번호. 예약 시간을 식별할 때 사용합니다.")
Long timeId,
@Schema(description = "예약 시간", type = "string", example = "09:00")
LocalTime startAt,
@Schema(description = "이미 예약이 완료된 시간인지 여부")
boolean alreadyBooked
) {
}

View File

@ -1,11 +0,0 @@
package roomescape.reservation.dto.response;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "예약 시간 정보 목록 응답", description = "특정 테마, 날짜에 대한 모든 예약 가능 시간 정보를 저장합니다.")
public record ReservationTimeInfosResponse(
@Schema(description = "특정 테마, 날짜에 대한 예약 가능 여부를 포함한 시간 목록") List<ReservationTimeInfoResponse> reservationTimes
) {
}

View File

@ -1,19 +0,0 @@
package roomescape.reservation.dto.response;
import java.time.LocalTime;
import io.swagger.v3.oas.annotations.media.Schema;
import roomescape.reservation.domain.ReservationTime;
@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.")
public record ReservationTimeResponse(
@Schema(description = "예약 시간 번호. 예약 시간을 식별할 때 사용합니다.")
Long id,
@Schema(description = "예약 시간", type = "string", example = "09:00")
LocalTime startAt
) {
public static ReservationTimeResponse from(ReservationTime reservationTime) {
return new ReservationTimeResponse(reservationTime.getId(), reservationTime.getStartAt());
}
}

View File

@ -1,11 +0,0 @@
package roomescape.reservation.dto.response;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "예약 시간 정보 목록 응답", description = "모든 예약 시간 조회 응답시 사용됩니다.")
public record ReservationTimesResponse(
@Schema(description = "모든 시간 목록") List<ReservationTimeResponse> times
) {
}

View File

@ -1,11 +0,0 @@
package roomescape.reservation.dto.response;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "예약 목록 조회 응답", description = "모든 예약 정보 조회 응답시 사용됩니다.")
public record ReservationsResponse(
@Schema(description = "모든 예약 및 대기 목록") List<ReservationResponse> reservations
) {
}

View File

@ -0,0 +1,53 @@
package roomescape.reservation.infrastructure.persistence
import com.fasterxml.jackson.annotation.JsonIgnore
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.persistence.*
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import java.time.LocalDate
@Entity
@Table(name = "reservation")
class ReservationEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
var date: LocalDate,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "time_id", nullable = false)
var reservationTime: ReservationTimeEntity,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "theme_id", nullable = false)
var theme: ThemeEntity,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: MemberEntity,
@Enumerated(value = EnumType.STRING)
var reservationStatus: ReservationStatus
) {
@JsonIgnore
fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING
@JsonIgnore
fun isSameMember(memberId: Long): Boolean {
return this.member.id == memberId
}
}
@Schema(description = "예약 상태를 나타냅니다.", allowableValues = ["CONFIRMED", "CONFIRMED_PAYMENT_REQUIRED", "WAITING"])
enum class ReservationStatus {
@Schema(description = "결제가 완료된 예약")
CONFIRMED,
@Schema(description = "결제가 필요한 예약")
CONFIRMED_PAYMENT_REQUIRED,
@Schema(description = "대기 중인 예약")
WAITING
}

View File

@ -0,0 +1,62 @@
package roomescape.reservation.infrastructure.persistence
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.MyReservationResponse
import java.time.LocalDate
interface ReservationRepository
: JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> {
fun findByReservationTime(reservationTime: ReservationTimeEntity): List<ReservationEntity>
fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity>
@Modifying
@Query("""
UPDATE ReservationEntity r
SET r.reservationStatus = :status
WHERE r.id = :id
""")
fun updateStatusByReservationId(
@Param(value = "id") reservationId: Long,
@Param(value = "status") statusForChange: ReservationStatus
): Int
@Query("""
SELECT EXISTS (
SELECT 1
FROM ReservationEntity r2
WHERE r2.id = :id
AND EXISTS (
SELECT 1 FROM ReservationEntity r
WHERE r.theme.id = r2.theme.id
AND r.reservationTime.id = r2.reservationTime.id
AND r.date = r2.date
AND r.reservationStatus != 'WAITING'
)
)
""")
fun isExistConfirmedReservation(@Param("id") reservationId: Long): Boolean
@Query("""
SELECT new roomescape.reservation.web.MyReservationResponse(
r.id,
t.name,
r.date,
r.reservationTime.startAt,
r.reservationStatus,
(SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.reservationTime = r.reservationTime AND r2.id < r.id),
p.paymentKey,
p.totalAmount
)
FROM ReservationEntity r
JOIN r.theme t
LEFT JOIN PaymentEntity p
ON p.reservation = r
WHERE r.member.id = :memberId
""")
fun findMyReservations(memberId: Long): List<MyReservationResponse>
}

View File

@ -0,0 +1,75 @@
package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.domain.Specification
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import java.time.LocalDate
class ReservationSearchSpecification(
private var spec: Specification<ReservationEntity> = Specification { _, _, _ -> null }
) {
fun sameThemeId(themeId: Long?): ReservationSearchSpecification = andIfNotNull(themeId?.let {
Specification { root, _, cb ->
cb.equal(root.get<ThemeEntity>("theme").get<Long>("id"), themeId)
}
})
fun sameMemberId(memberId: Long?): ReservationSearchSpecification = andIfNotNull(memberId?.let {
Specification { root, _, cb ->
cb.equal(root.get<MemberEntity>("member").get<Long>("id"), memberId)
}
})
fun sameTimeId(timeId: Long?): ReservationSearchSpecification = andIfNotNull(timeId?.let {
Specification { root, _, cb ->
cb.equal(root.get<ReservationTimeEntity>("reservationTime").get<Long>("id"), timeId)
}
})
fun sameDate(date: LocalDate?): ReservationSearchSpecification = andIfNotNull(date?.let {
Specification { root, _, cb ->
cb.equal(root.get<LocalDate>("date"), date)
}
})
fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.or(
cb.equal(
root.get<ReservationStatus>("reservationStatus"),
ReservationStatus.CONFIRMED
),
cb.equal(
root.get<ReservationStatus>("reservationStatus"),
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
)
}
fun waiting(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.equal(
root.get<ReservationStatus>("reservationStatus"),
ReservationStatus.WAITING
)
}
fun dateStartFrom(dateFrom: LocalDate?): ReservationSearchSpecification = andIfNotNull(dateFrom?.let {
Specification { root, _, cb ->
cb.greaterThanOrEqualTo(root.get("date"), dateFrom)
}
})
fun dateEndAt(dateTo: LocalDate?): ReservationSearchSpecification = andIfNotNull(dateTo?.let {
Specification { root, _, cb ->
cb.lessThanOrEqualTo(root.get("date"), dateTo)
}
})
fun build(): Specification<ReservationEntity> {
return this.spec
}
private fun andIfNotNull(condition: Specification<ReservationEntity>?): ReservationSearchSpecification {
condition?.let { this.spec = this.spec.and(condition) }
return this
}
}

View File

@ -0,0 +1,13 @@
package roomescape.reservation.infrastructure.persistence
import jakarta.persistence.*
import java.time.LocalTime
@Entity
@Table(name = "reservation_time")
class ReservationTimeEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
var startAt: LocalTime
)

View File

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

View File

@ -1,227 +0,0 @@
package roomescape.reservation.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import roomescape.common.exception.ErrorType;
import roomescape.common.exception.RoomescapeException;
import roomescape.member.business.MemberService;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.domain.repository.ReservationRepository;
import roomescape.reservation.domain.repository.ReservationSearchSpecification;
import roomescape.reservation.dto.request.AdminReservationRequest;
import roomescape.reservation.dto.request.ReservationRequest;
import roomescape.reservation.dto.request.WaitingRequest;
import roomescape.reservation.dto.response.MyReservationsResponse;
import roomescape.reservation.dto.response.ReservationResponse;
import roomescape.reservation.dto.response.ReservationsResponse;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.business.ThemeService;
@Service
@Transactional
public class ReservationService {
private final ReservationRepository reservationRepository;
private final ReservationTimeService reservationTimeService;
private final MemberService memberService;
private final ThemeService themeService;
public ReservationService(
ReservationRepository reservationRepository,
ReservationTimeService reservationTimeService,
MemberService memberService,
ThemeService themeService
) {
this.reservationRepository = reservationRepository;
this.reservationTimeService = reservationTimeService;
this.memberService = memberService;
this.themeService = themeService;
}
@Transactional(readOnly = true)
public ReservationsResponse findAllReservations() {
Specification<Reservation> spec = new ReservationSearchSpecification().confirmed().build();
List<ReservationResponse> response = findAllReservationByStatus(spec);
return new ReservationsResponse(response);
}
@Transactional(readOnly = true)
public ReservationsResponse findAllWaiting() {
Specification<Reservation> spec = new ReservationSearchSpecification().waiting().build();
List<ReservationResponse> response = findAllReservationByStatus(spec);
return new ReservationsResponse(response);
}
private List<ReservationResponse> findAllReservationByStatus(Specification<Reservation> spec) {
return reservationRepository.findAll(spec)
.stream()
.map(ReservationResponse::from)
.toList();
}
public void removeReservationById(Long reservationId, Long memberId) {
validateIsMemberAdmin(memberId);
reservationRepository.deleteById(reservationId);
}
public Reservation addReservation(ReservationRequest request, Long memberId) {
validateIsReservationExist(request.themeId(), request.timeId(), request.date());
Reservation reservation = getReservationForSave(request.timeId(), request.themeId(), request.date(), memberId,
ReservationStatus.CONFIRMED);
return reservationRepository.save(reservation);
}
public ReservationResponse addReservationByAdmin(AdminReservationRequest request) {
validateIsReservationExist(request.themeId(), request.timeId(), request.date());
return addReservationWithoutPayment(request.themeId(), request.timeId(), request.date(),
request.memberId(), ReservationStatus.CONFIRMED_PAYMENT_REQUIRED);
}
public ReservationResponse addWaiting(WaitingRequest request, Long memberId) {
validateMemberAlreadyReserve(request.themeId(), request.timeId(), request.date(), memberId);
return addReservationWithoutPayment(request.themeId(), request.timeId(), request.date(), memberId,
ReservationStatus.WAITING);
}
private ReservationResponse addReservationWithoutPayment(Long themeId, Long timeId, LocalDate date, Long memberId,
ReservationStatus status) {
Reservation reservation = getReservationForSave(timeId, themeId, date, memberId, status);
Reservation saved = reservationRepository.save(reservation);
return ReservationResponse.from(saved);
}
private void validateMemberAlreadyReserve(Long themeId, Long timeId, LocalDate date, Long memberId) {
Specification<Reservation> spec = new ReservationSearchSpecification()
.sameMemberId(memberId)
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build();
if (reservationRepository.exists(spec)) {
throw new RoomescapeException(ErrorType.HAS_RESERVATION_OR_WAITING, HttpStatus.BAD_REQUEST);
}
}
private void validateIsReservationExist(Long themeId, Long timeId, LocalDate date) {
Specification<Reservation> spec = new ReservationSearchSpecification()
.confirmed()
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build();
if (reservationRepository.exists(spec)) {
throw new RoomescapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT);
}
}
private void validateDateAndTime(
LocalDate requestDate,
ReservationTime requestReservationTime
) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime request = LocalDateTime.of(requestDate, requestReservationTime.getStartAt());
if (request.isBefore(now)) {
throw new RoomescapeException(ErrorType.RESERVATION_PERIOD_IN_PAST,
String.format("[now: %s %s | request: %s %s]",
now.toLocalDate(), now.toLocalTime(), requestDate, requestReservationTime.getStartAt()),
HttpStatus.BAD_REQUEST
);
}
}
private Reservation getReservationForSave(Long timeId, Long themeId, LocalDate date, Long memberId,
ReservationStatus status) {
ReservationTime time = reservationTimeService.findTimeById(timeId);
ThemeEntity theme = themeService.findThemeById(themeId);
Member member = memberService.findById(memberId);
validateDateAndTime(date, time);
return new Reservation(date, time, theme, member, status);
}
@Transactional(readOnly = true)
public ReservationsResponse findFilteredReservations(Long themeId, Long memberId, LocalDate dateFrom,
LocalDate dateTo) {
validateDateForSearch(dateFrom, dateTo);
Specification<Reservation> spec = new ReservationSearchSpecification()
.confirmed()
.sameThemeId(themeId)
.sameMemberId(memberId)
.dateStartFrom(dateFrom)
.dateEndAt(dateTo)
.build();
List<ReservationResponse> response = reservationRepository.findAll(spec)
.stream()
.map(ReservationResponse::from)
.toList();
return new ReservationsResponse(response);
}
private void validateDateForSearch(LocalDate startFrom, LocalDate endAt) {
if (startFrom == null || endAt == null) {
return;
}
if (startFrom.isAfter(endAt)) {
throw new RoomescapeException(ErrorType.INVALID_DATE_RANGE,
String.format("[startFrom: %s, endAt: %s", startFrom, endAt), HttpStatus.BAD_REQUEST);
}
}
@Transactional(readOnly = true)
public MyReservationsResponse findMemberReservations(Long memberId) {
return new MyReservationsResponse(reservationRepository.findMyReservations(memberId));
}
public void approveWaiting(Long reservationId, Long memberId) {
validateIsMemberAdmin(memberId);
if (reservationRepository.isExistConfirmedReservation(reservationId)) {
throw new RoomescapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT);
}
reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED);
}
public void cancelWaiting(Long reservationId, Long memberId) {
Reservation waiting = reservationRepository.findById(reservationId)
.filter(Reservation::isWaiting)
.filter(r -> r.isSameMember(memberId))
.orElseThrow(() -> throwReservationNotFound(reservationId));
reservationRepository.delete(waiting);
}
public void denyWaiting(Long reservationId, Long memberId) {
validateIsMemberAdmin(memberId);
Reservation waiting = reservationRepository.findById(reservationId)
.filter(Reservation::isWaiting)
.orElseThrow(() -> throwReservationNotFound(reservationId));
reservationRepository.delete(waiting);
}
private void validateIsMemberAdmin(Long memberId) {
Member member = memberService.findById(memberId);
if (member.isAdmin()) {
return;
}
throw new RoomescapeException(ErrorType.PERMISSION_DOES_NOT_EXIST, HttpStatus.FORBIDDEN);
}
private RoomescapeException throwReservationNotFound(Long reservationId) {
return new RoomescapeException(ErrorType.RESERVATION_NOT_FOUND,
String.format("[reservationId: %d]", reservationId), HttpStatus.NOT_FOUND);
}
}

View File

@ -1,100 +0,0 @@
package roomescape.reservation.service;
import java.time.LocalDate;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import roomescape.common.exception.ErrorType;
import roomescape.common.exception.RoomescapeException;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.domain.repository.ReservationRepository;
import roomescape.reservation.domain.repository.ReservationTimeRepository;
import roomescape.reservation.dto.request.ReservationTimeRequest;
import roomescape.reservation.dto.response.ReservationTimeInfoResponse;
import roomescape.reservation.dto.response.ReservationTimeInfosResponse;
import roomescape.reservation.dto.response.ReservationTimeResponse;
import roomescape.reservation.dto.response.ReservationTimesResponse;
@Service
@Transactional
public class ReservationTimeService {
private final ReservationTimeRepository reservationTimeRepository;
private final ReservationRepository reservationRepository;
public ReservationTimeService(
ReservationTimeRepository reservationTimeRepository,
ReservationRepository reservationRepository
) {
this.reservationTimeRepository = reservationTimeRepository;
this.reservationRepository = reservationRepository;
}
@Transactional(readOnly = true)
public ReservationTime findTimeById(Long id) {
return reservationTimeRepository.findById(id)
.orElseThrow(() -> new RoomescapeException(ErrorType.RESERVATION_TIME_NOT_FOUND,
String.format("[reservationTimeId: %d]", id), HttpStatus.BAD_REQUEST));
}
@Transactional(readOnly = true)
public ReservationTimesResponse findAllTimes() {
List<ReservationTimeResponse> response = reservationTimeRepository.findAll()
.stream()
.map(ReservationTimeResponse::from)
.toList();
return new ReservationTimesResponse(response);
}
public ReservationTimeResponse addTime(ReservationTimeRequest reservationTimeRequest) {
validateTimeDuplication(reservationTimeRequest);
ReservationTime reservationTime = reservationTimeRepository.save(reservationTimeRequest.toTime());
return ReservationTimeResponse.from(reservationTime);
}
private void validateTimeDuplication(ReservationTimeRequest reservationTimeRequest) {
List<ReservationTime> duplicateReservationTimes = reservationTimeRepository.findByStartAt(
reservationTimeRequest.startAt());
if (!duplicateReservationTimes.isEmpty()) {
throw new RoomescapeException(ErrorType.TIME_DUPLICATED,
String.format("[startAt: %s]", reservationTimeRequest.startAt()), HttpStatus.CONFLICT);
}
}
public void removeTimeById(Long id) {
ReservationTime reservationTime = findTimeById(id);
List<Reservation> usingTimeReservations = reservationRepository.findByReservationTime(reservationTime);
if (!usingTimeReservations.isEmpty()) {
throw new RoomescapeException(ErrorType.TIME_IS_USED_CONFLICT, String.format("[timeId: %d]", id),
HttpStatus.CONFLICT);
}
reservationTimeRepository.deleteById(id);
}
@Transactional(readOnly = true)
public ReservationTimeInfosResponse findAllAvailableTimesByDateAndTheme(LocalDate date, Long themeId) {
List<ReservationTime> allTimes = reservationTimeRepository.findAll();
List<Reservation> reservations = reservationRepository.findByThemeId(themeId);
List<ReservationTimeInfoResponse> response = allTimes.stream()
.map(time -> new ReservationTimeInfoResponse(time.getId(), time.getStartAt(),
isReservationBooked(reservations, date, time)))
.toList();
return new ReservationTimeInfosResponse(response);
}
private boolean isReservationBooked(List<Reservation> reservations, LocalDate date, ReservationTime time) {
return reservations.stream()
.anyMatch(reservation -> reservation.isSameDateAndTime(date, time));
}
}

View File

@ -1,56 +0,0 @@
package roomescape.reservation.service;
import java.time.OffsetDateTime;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import roomescape.payment.business.PaymentService;
import roomescape.payment.web.PaymentApprove;
import roomescape.payment.web.PaymentCancel;
import roomescape.payment.web.ReservationPaymentResponse;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.dto.request.ReservationRequest;
import roomescape.reservation.dto.response.ReservationResponse;
@Service
@Transactional
public class ReservationWithPaymentService {
private final ReservationService reservationService;
private final PaymentService paymentService;
public ReservationWithPaymentService(ReservationService reservationService,
PaymentService paymentService) {
this.reservationService = reservationService;
this.paymentService = paymentService;
}
public ReservationResponse addReservationWithPayment(ReservationRequest request,
PaymentApprove.Response paymentInfo,
Long memberId) {
Reservation reservation = reservationService.addReservation(request, memberId);
ReservationPaymentResponse reservationPaymentResponse = paymentService.savePayment(paymentInfo, reservation);
return reservationPaymentResponse.reservation();
}
public void saveCanceledPayment(PaymentCancel.Response cancelInfo, OffsetDateTime approvedAt, String paymentKey) {
paymentService.saveCanceledPayment(cancelInfo, approvedAt, paymentKey);
}
public PaymentCancel.Request removeReservationWithPayment(Long reservationId, Long memberId) {
PaymentCancel.Request paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservationId);
reservationService.removeReservationById(reservationId, memberId);
return paymentCancelRequest;
}
@Transactional(readOnly = true)
public boolean isNotPaidReservation(Long reservationId) {
return !paymentService.isReservationPaid(reservationId);
}
public void updateCanceledTime(String paymentKey, OffsetDateTime canceledAt) {
paymentService.updateCanceledTime(paymentKey, canceledAt);
}
}

View File

@ -0,0 +1,159 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.common.exception.RoomescapeException
import roomescape.payment.infrastructure.client.TossPaymentClient
import roomescape.payment.web.PaymentApprove
import roomescape.payment.web.PaymentCancel
import roomescape.reservation.business.ReservationService
import roomescape.reservation.business.ReservationWithPaymentService
import roomescape.reservation.docs.ReservationAPI
import java.net.URI
import java.time.LocalDate
@RestController
class ReservationController(
private val reservationWithPaymentService: ReservationWithPaymentService,
private val reservationService: ReservationService,
private val paymentClient: TossPaymentClient
) : ReservationAPI {
@GetMapping("/reservations")
override fun getAllReservations(): ResponseEntity<CommonApiResponse<ReservationsResponse>> {
val response: ReservationsResponse = reservationService.findAllReservations()
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/reservations-mine")
override fun getMemberReservations(
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<MyReservationsResponse>> {
val response: MyReservationsResponse = reservationService.findMemberReservations(memberId)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/reservations/search")
override fun getReservationBySearching(
@RequestParam(required = false) themeId: Long?,
@RequestParam(required = false) memberId: Long?,
@RequestParam(required = false) dateFrom: LocalDate?,
@RequestParam(required = false) dateTo: LocalDate?
): ResponseEntity<CommonApiResponse<ReservationsResponse>> {
val response: ReservationsResponse = reservationService.findFilteredReservations(themeId, memberId, dateFrom, dateTo)
return ResponseEntity.ok(CommonApiResponse(response))
}
@DeleteMapping("/reservations/{id}")
override fun removeReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> {
if (reservationWithPaymentService.isNotPaidReservation(reservationId)) {
reservationService.removeReservationById(reservationId, memberId)
return ResponseEntity.noContent().build()
}
val paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment(
reservationId, memberId)
val paymentCancelResponse = paymentClient.cancelPayment(paymentCancelRequest)
reservationWithPaymentService.updateCanceledTime(paymentCancelRequest.paymentKey,
paymentCancelResponse.canceledAt)
return ResponseEntity.noContent().build()
}
@PostMapping("/reservations")
override fun saveReservation(
@Valid @RequestBody reservationRequest: ReservationRequest,
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationResponse>> {
val paymentRequest: PaymentApprove.Request = reservationRequest.paymentRequest
val paymentResponse: PaymentApprove.Response = paymentClient.confirmPayment(paymentRequest)
try {
val reservationResponse: ReservationResponse = reservationWithPaymentService.addReservationWithPayment(
reservationRequest,
paymentResponse,
memberId
)
return ResponseEntity.created(URI.create("/reservations/${reservationResponse.id}"))
.body(CommonApiResponse(reservationResponse))
} catch (e: RoomescapeException) {
val cancelRequest = PaymentCancel.Request(paymentRequest.paymentKey,
paymentRequest.amount, e.message!!)
val paymentCancelResponse = paymentClient.cancelPayment(cancelRequest)
reservationWithPaymentService.saveCanceledPayment(paymentCancelResponse, paymentResponse.approvedAt,
paymentRequest.paymentKey)
throw e
}
}
@PostMapping("/reservations/admin")
override fun saveReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationRequest
): ResponseEntity<CommonApiResponse<ReservationResponse>> {
val response: ReservationResponse =
reservationService.addReservationByAdmin(adminReservationRequest)
return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response))
}
@GetMapping("/reservations/waiting")
override fun getAllWaiting(): ResponseEntity<CommonApiResponse<ReservationsResponse>> {
val response: ReservationsResponse = reservationService.findAllWaiting()
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/reservations/waiting")
override fun saveWaiting(
@Valid @RequestBody waitingRequest: WaitingRequest,
@MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationResponse>> {
val response: ReservationResponse = reservationService.addWaiting(
waitingRequest,
memberId
)
return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response))
}
@DeleteMapping("/reservations/waiting/{id}")
override fun deleteWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.cancelWaiting(reservationId, memberId)
return ResponseEntity.noContent().build()
}
@PostMapping("/reservations/waiting/{id}/approve")
override fun approveWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.approveWaiting(reservationId, memberId)
return ResponseEntity.ok().build()
}
@PostMapping("/reservations/waiting/{id}/deny")
override fun denyWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.denyWaiting(reservationId, memberId)
return ResponseEntity.noContent().build()
}
}

View File

@ -0,0 +1,66 @@
package roomescape.reservation.web
import com.fasterxml.jackson.annotation.JsonIgnore
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.payment.web.PaymentApprove
import java.time.LocalDate
@Schema(name = "관리자 예약 저장 요청", description = "관리자의 예약 저장 요청시 사용됩니다.")
@JvmRecord
data class AdminReservationRequest(
@JvmField @field:Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
val date: LocalDate,
@JvmField @field:Schema(description = "예약 시간 ID.", example = "1")
val timeId: Long,
@JvmField @field:Schema(description = "테마 ID", example = "1")
val themeId: Long,
@JvmField @field:Schema(description = "회원 ID", example = "1")
val memberId: Long
)
@Schema(name = "회원의 예약 저장 요청", description = "회원의 예약 요청시 사용됩니다.")
@JvmRecord
data class ReservationRequest(
@JvmField
@field:Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
val date: LocalDate,
@JvmField
@field:Schema(description = "예약 시간 ID.", example = "1")
val timeId: Long,
@JvmField @field:Schema(description = "테마 ID", example = "1")
val themeId: Long,
@field:Schema(description = "결제 위젯을 통해 받은 결제 키")
val paymentKey: String,
@field:Schema(description = "결제 위젯을 통해 받은 주문번호.")
val orderId: String,
@field:Schema(description = "결제 위젯을 통해 받은 결제 금액")
val amount: Long,
@field:Schema(description = "결제 타입", example = "NORMAL")
val paymentType: String
) {
@get:JsonIgnore
val paymentRequest: PaymentApprove.Request
get() = PaymentApprove.Request(paymentKey, orderId, amount, paymentType)
}
@Schema(name = "예약 대기 저장 요청", description = "회원의 예약 대기 요청시 사용됩니다.")
@JvmRecord
data class WaitingRequest(
@JvmField
@field:Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
val date: LocalDate,
@JvmField
@field:Schema(description = "예약 시간 ID", example = "1")
val timeId: Long,
@JvmField
@field:Schema(description = "테마 ID", example = "1")
val themeId: Long
)

View File

@ -0,0 +1,103 @@
package roomescape.reservation.web
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.member.web.MemberResponse
import roomescape.member.web.toResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.web.ThemeResponse
import roomescape.theme.web.toResponse
import java.time.LocalDate
import java.time.LocalTime
@Schema(name = "회원의 예약 및 대기 응답", description = "회원의 예약 및 대기 정보 응답시 사용됩니다.")
@JvmRecord
data class MyReservationResponse(
@field:Schema(description = "예약 번호. 예약을 식별할 때 사용합니다.")
val id: Long,
@field:Schema(description = "테마 이름")
val themeName: String,
@field:Schema(description = "예약 날짜", type = "string", example = "2022-12-31")
val date: LocalDate,
@field:Schema(description = "예약 시간", type = "string", example = "09:00")
val time: LocalTime,
@field:Schema(description = "예약 상태", type = "string")
val status: ReservationStatus,
@field:Schema(description = "예약 대기 상태일 때의 대기 순번. 확정된 예약은 0의 값을 가집니다.")
val rank: Long,
@field:Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.")
val paymentKey: String?,
@field:Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.")
val amount: Long?
)
@Schema(name = "회원의 예약 및 대기 목록 조회 응답", description = "회원의 예약 및 대기 목록 조회 응답시 사용됩니다.")
@JvmRecord
data class MyReservationsResponse(
@field:Schema(description = "현재 로그인한 회원의 예약 및 대기 목록")
val reservations: List<MyReservationResponse>
)
@Schema(name = "예약 정보", description = "예약 저장 및 조회 응답에 사용됩니다.")
@JvmRecord
data class ReservationResponse(
@JvmField
@field:Schema(description = "예약 번호. 예약을 식별할 때 사용합니다.")
val id: Long,
@field:Schema(description = "예약 날짜", type = "string", example = "2022-12-31")
val date: LocalDate,
@field:Schema(description = "예약한 회원 정보")
@field:JsonProperty("member")
val member: MemberResponse,
@field:Schema(description = "예약 시간 정보")
@field:JsonProperty("time")
val time: ReservationTimeResponse,
@field:Schema(description = "예약한 테마 정보")
@field:JsonProperty("theme")
val theme: ThemeResponse,
@field:Schema(description = "예약 상태", type = "string")
val status: ReservationStatus
) {
companion object {
@JvmStatic
fun from(reservation: ReservationEntity): ReservationResponse {
return ReservationResponse(
reservation.id!!,
reservation.date,
reservation.member.toResponse(),
reservation.reservationTime.toResponse(),
reservation.theme.toResponse(),
reservation.reservationStatus
)
}
}
}
fun ReservationEntity.toResponse(): ReservationResponse = ReservationResponse(
id = this.id!!,
date = this.date,
member = this.member.toResponse(),
time = this.reservationTime.toResponse(),
theme = this.theme.toResponse(),
status = this.reservationStatus
)
@Schema(name = "예약 목록 조회 응답", description = "모든 예약 정보 조회 응답시 사용됩니다.")
@JvmRecord
data class ReservationsResponse(
@field:Schema(description = "모든 예약 및 대기 목록")
val reservations: List<ReservationResponse>
)

View File

@ -0,0 +1,51 @@
package roomescape.reservation.web
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.business.ReservationTimeService
import roomescape.reservation.docs.ReservationTimeAPI
import java.net.URI
import java.time.LocalDate
@RestController
class ReservationTimeController(
private val reservationTimeService: ReservationTimeService
) : ReservationTimeAPI {
@GetMapping("/times")
override fun getAllTimes(): ResponseEntity<CommonApiResponse<ReservationTimesResponse>> {
val response: ReservationTimesResponse = reservationTimeService.findAllTimes()
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/times")
override fun saveTime(
@Valid @RequestBody reservationTimeRequest: ReservationTimeRequest,
): ResponseEntity<CommonApiResponse<ReservationTimeResponse>> {
val response: ReservationTimeResponse = reservationTimeService.addTime(reservationTimeRequest)
return ResponseEntity
.created(URI.create("/times/${response.id}"))
.body(CommonApiResponse(response))
}
@DeleteMapping("/times/{id}")
override fun removeTime(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> {
reservationTimeService.removeTimeById(id)
return ResponseEntity.noContent().build()
}
@GetMapping("/times/filter")
override fun findAllAvailableReservationTimes(
@RequestParam date: LocalDate,
@RequestParam themeId: Long
): ResponseEntity<CommonApiResponse<ReservationTimeInfosResponse>> {
val response: ReservationTimeInfosResponse = reservationTimeService.findAllAvailableTimesByDateAndTheme(date, themeId)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -0,0 +1,73 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.reservation.infrastructure.persistence.ReservationTimeEntity
import java.time.LocalTime
@Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.")
@JvmRecord
data class ReservationTimeRequest(
@JvmField
@field:Schema(description = "예약 시간. HH:mm 형식으로 입력해야 합니다.", type = "string", example = "09:00")
val startAt: LocalTime
)
@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.")
@JvmRecord
data class ReservationTimeResponse(
@JvmField
@field:Schema(description = "예약 시간 번호. 예약 시간을 식별할 때 사용합니다.")
val id: Long,
@field:Schema(description = "예약 시간", type = "string", example = "09:00")
val startAt: LocalTime
) {
companion object {
@JvmStatic
fun from(reservationTime: ReservationTimeEntity): ReservationTimeResponse {
return ReservationTimeResponse(reservationTime.id!!, reservationTime.startAt)
}
}
}
fun ReservationTimeEntity.toResponse(): ReservationTimeResponse = ReservationTimeResponse(
this.id!!, this.startAt
)
@Schema(name = "예약 시간 정보 목록 응답", description = "모든 예약 시간 조회 응답시 사용됩니다.")
@JvmRecord
data class ReservationTimesResponse(
@field:Schema(description = "모든 시간 목록")
val times: List<ReservationTimeResponse>
)
fun List<ReservationTimeEntity>.toResponses(): ReservationTimesResponse = ReservationTimesResponse(
this.map { it.toResponse() }
)
@Schema(name = "특정 테마, 날짜에 대한 시간 정보 응답", description = "특정 날짜와 테마에 대해, 예약 가능 여부를 포함한 시간 정보를 저장합니다.")
@JvmRecord
data class ReservationTimeInfoResponse(
@field:Schema(description = "예약 시간 번호. 예약 시간을 식별할 때 사용합니다.")
val id: Long,
@field:Schema(description = "예약 시간", type = "string", example = "09:00")
val startAt: LocalTime,
@field:Schema(description = "이미 예약이 완료된 시간인지 여부")
val alreadyBooked: Boolean
)
fun ReservationTimeEntity.toInfoResponse(alreadyBooked: Boolean): ReservationTimeInfoResponse = ReservationTimeInfoResponse(
id = this.id!!,
startAt = this.startAt,
alreadyBooked = alreadyBooked
)
@Schema(name = "예약 시간 정보 목록 응답", description = "특정 테마, 날짜에 대한 모든 예약 가능 시간 정보를 저장합니다.")
@JvmRecord
data class ReservationTimeInfosResponse(
@field:Schema(description = "특정 테마, 날짜에 대한 예약 가능 여부를 포함한 시간 목록")
val times: List<ReservationTimeInfoResponse>
)

View File

@ -9,7 +9,7 @@ interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
@Query(value = """
SELECT t
FROM ThemeEntity t
RIGHT JOIN Reservation r ON t.id = r.theme.id
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
@ -24,7 +24,7 @@ interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
@Query(value = """
SELECT EXISTS(
SELECT 1
FROM Reservation r
FROM ReservationEntity r
WHERE r.theme.id = :id
)
""")

View File

@ -11,7 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
function render(data) {
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.data.myReservationResponses.forEach(item => {
data.data.reservations.forEach(item => {
const row = tableBody.insertRow();
const theme = item.themeName;

View File

@ -7,15 +7,15 @@ import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull
import roomescape.util.JwtFixture
import roomescape.util.MemberFixture
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Member
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.service.AuthService
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.util.JwtFixture
import roomescape.util.MemberFixture
class AuthServiceTest : BehaviorSpec({
@ -24,7 +24,7 @@ class AuthServiceTest : BehaviorSpec({
val jwtHandler: JwtHandler = JwtFixture.create()
val authService = AuthService(memberService, jwtHandler)
val user: Member = MemberFixture.user()
val user: MemberEntity = MemberFixture.user()
Given("로그인 요청을 받으면") {
When("이메일과 비밀번호로 회원을 찾고") {

View File

@ -5,9 +5,9 @@ import io.jsonwebtoken.SignatureAlgorithm
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import roomescape.util.JwtFixture
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.util.JwtFixture
import java.util.*
import kotlin.random.Random

View File

@ -1,147 +0,0 @@
package roomescape.payment.business;
import static org.assertj.core.api.Assertions.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
import roomescape.common.exception.RoomescapeException;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity;
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository;
import roomescape.payment.web.PaymentApprove;
import roomescape.payment.web.PaymentCancel;
import roomescape.payment.web.ReservationPaymentResponse;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.domain.repository.ReservationRepository;
import roomescape.reservation.domain.repository.ReservationTimeRepository;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.infrastructure.persistence.ThemeRepository;
@SpringBootTest
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
class PaymentServiceTest {
@Autowired
private PaymentService paymentService;
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private MemberRepository memberRepository;
@Autowired
private ReservationTimeRepository reservationTimeRepository;
@Autowired
private ThemeRepository themeRepository;
@Autowired
private CanceledPaymentRepository canceledPaymentRepository;
@Test
@DisplayName("결제 정보를 저장한다.")
void savePayment() {
// given
PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id",
OffsetDateTime.now(), 10000L);
LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
Member member = memberRepository.save(new Member(null, "member", "email@email.com", "password", Role.MEMBER));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "name", "desc", "thumbnail"));
Reservation reservation = reservationRepository.save(new Reservation(date, time, theme, member,
ReservationStatus.CONFIRMED));
// when
ReservationPaymentResponse reservationPaymentResponse = paymentService.savePayment(paymentInfo, reservation);
// then
assertThat(reservationPaymentResponse.reservation().id()).isEqualTo(reservation.getId());
assertThat(reservationPaymentResponse.paymentKey()).isEqualTo(paymentInfo.paymentKey);
}
@Test
@DisplayName("예약 ID로 결제 정보를 제거하고, 결제 취소 테이블에 취소 정보를 저장한다.")
void cancelPaymentByAdmin() {
// given
PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id",
OffsetDateTime.now(), 10000L);
LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
Member member = memberRepository.save(new Member(null, "member", "email@email.com", "password", Role.MEMBER));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "name", "desc", "thumbnail"));
Reservation reservation = reservationRepository.save(new Reservation(date, time, theme, member,
ReservationStatus.CONFIRMED));
paymentService.savePayment(paymentInfo, reservation);
// when
PaymentCancel.Request paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservation.getId());
// then
assertThat(canceledPaymentRepository.findByPaymentKey("payment-key")).isNotNull();
assertThat(paymentCancelRequest.paymentKey).isEqualTo(paymentInfo.paymentKey);
assertThat(paymentCancelRequest.cancelReason).isEqualTo("고객 요청");
assertThat(paymentCancelRequest.amount).isEqualTo(10000L);
}
@Test
@DisplayName("입력된 예약 ID에 대한 결제 정보가 없으면 예외가 발생한다.")
void cancelPaymentByAdminWithNonExistentReservationId() {
// given
Long nonExistentReservationId = 1L;
// when
assertThatThrownBy(() -> paymentService.cancelPaymentByAdmin(nonExistentReservationId))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("결제 취소 정보에 있는 취소 시간을 업데이트한다.")
void updateCanceledTime() {
// given
PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id",
OffsetDateTime.now(), 10000L);
LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
Member member = memberRepository.save(new Member(null, "member", "email@email.com", "password", Role.MEMBER));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "name", "desc", "thumbnail"));
Reservation reservation = reservationRepository.save(new Reservation(date, time, theme, member,
ReservationStatus.CONFIRMED));
paymentService.savePayment(paymentInfo, reservation);
paymentService.cancelPaymentByAdmin(reservation.getId());
// when
OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(2L);
paymentService.updateCanceledTime(paymentInfo.paymentKey, canceledAt);
// then
CanceledPaymentEntity canceledPayment = canceledPaymentRepository.findByPaymentKey(paymentInfo.paymentKey);
assertThat(canceledPayment).isNotNull();
assertThat(canceledPayment.getCanceledAt()).isEqualTo(canceledAt);
}
@Test
@DisplayName("결제 취소 시간을 업데이트 할 때, 입력한 paymentKey가 존재하지 않으면 예외가 발생한다.")
void updateCanceledTimeWithNonExistentPaymentKey() {
// given
OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(2L);
// when
assertThatThrownBy(() -> paymentService.updateCanceledTime("non-existent-payment-key", canceledAt))
.isInstanceOf(RoomescapeException.class);
}
}

View File

@ -17,7 +17,7 @@ import roomescape.payment.web.PaymentCancel
import roomescape.util.PaymentFixture
import java.time.OffsetDateTime
class PaymentServiceKTest : FunSpec({
class PaymentServiceTest : FunSpec({
val paymentRepository: PaymentRepository = mockk()
val canceledPaymentRepository: CanceledPaymentRepository = mockk()

View File

@ -6,6 +6,7 @@ import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.util.PaymentFixture
import roomescape.util.ReservationFixture
@ -15,23 +16,23 @@ class PaymentRepositoryTest(
@Autowired val entityManager: EntityManager
) : FunSpec() {
var reservationId: Long = 0L
lateinit var reservation: ReservationEntity
init {
context("existsByReservationId") {
beforeTest {
reservationId = setupReservation()
PaymentFixture.create(reservationId = reservationId)
reservation = setupReservation()
PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) }
}
test("true") {
paymentRepository.existsByReservationId(reservationId)
paymentRepository.existsByReservationId(reservation.id!!)
.also { it shouldBe true }
}
test("false") {
paymentRepository.existsByReservationId(reservationId + 1)
paymentRepository.existsByReservationId(reservation.id!! + 1L)
.also { it shouldBe false }
}
}
@ -40,20 +41,20 @@ class PaymentRepositoryTest(
lateinit var paymentKey: String
beforeTest {
reservationId = setupReservation()
paymentKey = PaymentFixture.create(reservationId = reservationId)
reservation = setupReservation()
paymentKey = PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) }
.paymentKey
}
test("정상 반환") {
paymentRepository.findPaymentKeyByReservationId(reservationId)
paymentRepository.findPaymentKeyByReservationId(reservation.id!!)
?.let { it shouldBe paymentKey }
?: throw AssertionError("Unexpected null value")
}
test("null 반환") {
paymentRepository.findPaymentKeyByReservationId(reservationId + 1)
paymentRepository.findPaymentKeyByReservationId(reservation.id!! + 1)
.also { it shouldBe null }
}
}
@ -62,8 +63,8 @@ class PaymentRepositoryTest(
lateinit var payment: PaymentEntity
beforeTest {
reservationId = setupReservation()
payment = PaymentFixture.create(reservationId = reservationId)
reservation = setupReservation()
payment = PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) }
}
@ -89,7 +90,7 @@ class PaymentRepositoryTest(
}
}
private fun setupReservation(): Long {
private fun setupReservation(): ReservationEntity {
return ReservationFixture.create().also {
entityManager.persist(it.member)
entityManager.persist(it.theme)
@ -98,6 +99,6 @@ class PaymentRepositoryTest(
entityManager.flush()
entityManager.clear()
}.id
}
}
}

View File

@ -0,0 +1,175 @@
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 roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Role
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.theme.business.ThemeService
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.ReservationTimeFixture
import java.time.LocalDate
import java.time.LocalTime
class ReservationServiteTest : FunSpec({
val reservationRepository: ReservationRepository = mockk()
val reservationTimeService: ReservationTimeService = mockk()
val memberService: MemberService = mockk()
val themeService: ThemeService = mockk()
val reservationService = ReservationService(
reservationRepository,
reservationTimeService,
memberService,
themeService
)
context("예약을 추가할 때") {
test("이미 예약이 있으면 예외를 던진다.") {
every {
reservationRepository.exists(any())
} returns true
val reservationRequest = ReservationFixture.createRequest()
shouldThrow<RoomescapeException> {
reservationService.addReservation(reservationRequest, 1L)
}.also {
it.errorType shouldBe ErrorType.RESERVATION_DUPLICATED
}
}
context("날짜, 시간이 잘못 입력되면 예외를 던진다.") {
every {
reservationRepository.exists(any())
} returns false
every {
themeService.findThemeById(any())
} returns mockk()
every {
memberService.findById(any())
} returns mockk()
test("지난 날짜이면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now().minusDays(1)
)
every {
reservationTimeService.findTimeById(any())
} returns ReservationTimeFixture.create()
shouldThrow<RoomescapeException> {
reservationService.addReservation(reservationRequest, 1L)
}.also {
it.errorType shouldBe ErrorType.RESERVATION_PERIOD_IN_PAST
}
}
test("지난 시간이면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now(),
)
every {
reservationTimeService.findTimeById(reservationRequest.timeId)
} returns ReservationTimeFixture.create(
startAt = LocalTime.now().minusMinutes(1)
)
shouldThrow<RoomescapeException> {
reservationService.addReservation(reservationRequest, 1L)
}.also {
it.errorType shouldBe ErrorType.RESERVATION_PERIOD_IN_PAST
}
}
}
}
context("예약 대기를 걸 때") {
test("이미 예약한 회원이 같은 날짜와 테마로 대기를 걸면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now(),
themeId = 1L,
timeId = 1L,
)
every {
reservationRepository.exists(any())
} returns true
shouldThrow<RoomescapeException> {
val waitingRequest = ReservationFixture.createWaitingRequest(
date = reservationRequest.date,
themeId = reservationRequest.themeId,
timeId = reservationRequest.timeId
)
reservationService.addWaiting(waitingRequest, 1L)
}.also {
it.errorType shouldBe ErrorType.HAS_RESERVATION_OR_WAITING
}
}
}
context("예약을 조회할 때") {
test("종료 날짜가 시작 날짜보다 이전이면 예외를 던진다.") {
val startFrom = LocalDate.now()
val endAt = startFrom.minusDays(1)
shouldThrow<RoomescapeException> {
reservationService.findFilteredReservations(
null,
null,
startFrom,
endAt
)
}.also {
it.errorType shouldBe ErrorType.INVALID_DATE_RANGE
}
}
}
context("대기중인 예약을 승인할 때") {
test("관리자가 아니면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.MEMBER)
every {
memberService.findById(any())
} returns member
shouldThrow<RoomescapeException> {
reservationService.approveWaiting(1L, member.id!!)
}.also {
it.errorType shouldBe ErrorType.PERMISSION_DOES_NOT_EXIST
}
}
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<RoomescapeException> {
reservationService.approveWaiting(reservationId, member.id!!)
}.also {
it.errorType shouldBe ErrorType.RESERVATION_DUPLICATED
}
}
}
})

View File

@ -0,0 +1,92 @@
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 org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationTimeRepository
import roomescape.reservation.web.ReservationTimeRequest
import roomescape.util.ReservationTimeFixture
import java.time.LocalTime
class ReservationTimeServiceTest : FunSpec({
val reservationTimeRepository: ReservationTimeRepository = mockk()
val reservationRepository: ReservationRepository = mockk()
val reservationTimeService = ReservationTimeService(
reservationTimeRepository = reservationTimeRepository,
reservationRepository = reservationRepository
)
context("findTimeById") {
test("시간을 찾을 수 없으면 400 에러를 던진다.") {
val id = 1L
// Mocking the behavior of reservationTimeRepository.findByIdOrNull
every { reservationTimeRepository.findByIdOrNull(id) } returns null
shouldThrow<RoomescapeException> {
reservationTimeService.findTimeById(id)
}.apply {
errorType shouldBe ErrorType.RESERVATION_TIME_NOT_FOUND
httpStatus shouldBe HttpStatus.BAD_REQUEST
}
}
}
context("addTime") {
test("중복된 시간이 있으면 409 에러를 던진다.") {
val request = ReservationTimeRequest(startAt = LocalTime.of(10, 0))
// Mocking the behavior of reservationTimeRepository.findByStartAt
every { reservationTimeRepository.existsByStartAt(request.startAt) } returns true
shouldThrow<RoomescapeException> {
reservationTimeService.addTime(request)
}.apply {
errorType shouldBe ErrorType.TIME_DUPLICATED
httpStatus shouldBe HttpStatus.CONFLICT
}
}
}
context("removeTimeById") {
test("시간을 찾을 수 없으면 400 에러를 던진다.") {
val id = 1L
// Mocking the behavior of reservationTimeRepository.findByIdOrNull
every { reservationTimeRepository.findByIdOrNull(id) } returns null
shouldThrow<RoomescapeException> {
reservationTimeService.removeTimeById(id)
}.apply {
errorType shouldBe ErrorType.RESERVATION_TIME_NOT_FOUND
httpStatus shouldBe HttpStatus.BAD_REQUEST
}
}
test("예약이 있는 시간이면 409 에러를 던진다.") {
val id = 1L
val reservationTime = ReservationTimeFixture.create()
// Mocking the behavior of reservationTimeRepository.findByIdOrNull
every { reservationTimeRepository.findByIdOrNull(id) } returns reservationTime
// Mocking the behavior of reservationRepository.findByReservationTime
every { reservationRepository.findByReservationTime(reservationTime) } returns listOf(mockk())
shouldThrow<RoomescapeException> {
reservationTimeService.removeTimeById(id)
}.apply {
errorType shouldBe ErrorType.TIME_IS_USED_CONFLICT
httpStatus shouldBe HttpStatus.CONFLICT
}
}
}
})

View File

@ -0,0 +1,122 @@
package roomescape.reservation.business
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import roomescape.payment.business.PaymentService
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.web.PaymentCancel
import roomescape.payment.web.toReservationPaymentResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.ReservationRequest
import roomescape.reservation.web.ReservationResponse
import roomescape.util.*
class ReservationWithPaymentServiceTest : FunSpec({
val reservationService: ReservationService = mockk()
val paymentService: PaymentService = mockk()
val reservationWithPaymentService = ReservationWithPaymentService(
reservationService = reservationService,
paymentService = paymentService
)
val reservationRequest: ReservationRequest = ReservationFixture.createRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse()
val memberId = 1L
val reservationEntity: ReservationEntity = ReservationFixture.create(
id = 1L,
date = reservationRequest.date,
reservationTime = ReservationTimeFixture.create(id = reservationRequest.timeId),
theme = ThemeFixture.create(id = reservationRequest.themeId),
member = MemberFixture.create(id = memberId),
status = ReservationStatus.CONFIRMED
)
val paymentEntity: PaymentEntity = PaymentFixture.create(
id = 1L,
orderId = reservationRequest.orderId,
paymentKey = reservationRequest.paymentKey,
totalAmount = reservationRequest.amount,
reservation = reservationEntity,
)
context("addReservationWithPayment") {
test("예약 및 결제 정보를 저장한다.") {
every {
reservationService.addReservation(reservationRequest, memberId)
} returns reservationEntity
every {
paymentService.savePayment(paymentApproveResponse, reservationEntity)
} returns paymentEntity.toReservationPaymentResponse()
val result: ReservationResponse = reservationWithPaymentService.addReservationWithPayment(
request = reservationRequest,
paymentInfo = paymentApproveResponse,
memberId = memberId
)
assertSoftly(result) {
this.id shouldBe reservationEntity.id
this.date shouldBe reservationEntity.date
this.member.id shouldBe reservationEntity.member.id
this.time.id shouldBe reservationEntity.reservationTime.id
this.theme.id shouldBe reservationEntity.theme.id
this.status shouldBe ReservationStatus.CONFIRMED
}
}
context("removeReservationWithPayment") {
test("예약 및 결제 정보를 삭제하고, 결제 취소 정보를 저장한다.") {
val paymentCancelRequest: PaymentCancel.Request = PaymentFixture.createCancelRequest().copy(
paymentKey = paymentEntity.paymentKey,
amount = paymentEntity.totalAmount,
cancelReason = "고객 요청"
)
every {
paymentService.cancelPaymentByAdmin(reservationEntity.id!!)
} returns paymentCancelRequest
every {
reservationService.removeReservationById(reservationEntity.id!!, reservationEntity.member.id!!)
} just Runs
val result: PaymentCancel.Request = reservationWithPaymentService.removeReservationWithPayment(
reservationId = reservationEntity.id!!,
memberId = reservationEntity.member.id!!
)
result shouldBe paymentCancelRequest
}
}
context("isNotPaidReservation") {
test("결제된 예약이면 true를 반환한다.") {
every {
paymentService.isReservationPaid(reservationEntity.id!!)
} returns false
val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!)
result shouldBe true
}
test("결제되지 않은 예약이면 false를 반환한다.") {
every {
paymentService.isReservationPaid(reservationEntity.id!!)
} returns true
val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!)
result shouldBe false
}
}
}
})

View File

@ -1,621 +0,0 @@
package roomescape.reservation.controller;
import static org.assertj.core.api.Assertions.*;
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.http.Header;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.payment.infrastructure.client.TossPaymentClient;
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.PaymentApprove;
import roomescape.payment.web.PaymentCancel;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.domain.repository.ReservationRepository;
import roomescape.reservation.domain.repository.ReservationTimeRepository;
import roomescape.reservation.dto.request.AdminReservationRequest;
import roomescape.reservation.dto.request.ReservationRequest;
import roomescape.reservation.dto.request.WaitingRequest;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.infrastructure.persistence.ThemeRepository;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
public class ReservationControllerTest {
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private ReservationTimeRepository reservationTimeRepository;
@Autowired
private ThemeRepository themeRepository;
@Autowired
private MemberRepository memberRepository;
@Autowired
private PaymentRepository paymentRepository;
@Autowired
private CanceledPaymentRepository canceledPaymentRepository;
@MockBean
private TossPaymentClient paymentClient;
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
@DisplayName("처음으로 등록하는 예약의 id는 1이다.")
void firstPost() {
String accessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234");
LocalTime time = LocalTime.of(17, 30);
LocalDate date = LocalDate.now().plusDays(1L);
reservationTimeRepository.save(new ReservationTime(time));
themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Map<String, String> reservationParams = Map.of(
"date", date.toString(),
"timeId", "1",
"themeId", "1",
"paymentKey", "pk",
"orderId", "oi",
"amount", "1000",
"paymentType", "DEFAULT"
);
when(paymentClient.confirmPayment(any(PaymentApprove.Request.class))).thenReturn(
new PaymentApprove.Response("pk", "oi", OffsetDateTime.of(date, time, ZoneOffset.ofHours(9)), 1000L));
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header("Cookie", accessTokenCookie)
.body(reservationParams)
.when().post("/reservations")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/reservations/1");
}
@Test
@DisplayName("대기중인 예약을 취소한다.")
void cancelWaiting() {
// given
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
String accessTokenCookie = getAccessTokenCookieByLogin("email@email.com", "password");
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member1 = memberRepository.save(new Member(null, "name1", "email1r@email.com", "password", Role.MEMBER));
// when
reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member1,
ReservationStatus.CONFIRMED));
Reservation waiting = reservationRepository.save(
new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member,
ReservationStatus.WAITING));
// then
RestAssured.given().log().all()
.port(port)
.header("Cookie", accessTokenCookie)
.when().delete("/reservations/waiting/{id}", waiting.getId())
.then().log().all()
.statusCode(204);
}
@Test
@DisplayName("회원은 자신이 아닌 다른 회원의 예약을 취소할 수 없다.")
void cantCancelOtherMembersWaiting() {
// given
Member confirmedMember = memberRepository.save(
new Member(null, "name", "email@email.com", "password", Role.MEMBER));
String accessTokenCookie = getAccessTokenCookieByLogin("email@email.com", "password");
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member waitingMember = memberRepository.save(
new Member(null, "name1", "email1r@email.com", "password", Role.MEMBER));
// when
reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, confirmedMember,
ReservationStatus.CONFIRMED));
Reservation waiting = reservationRepository.save(
new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, waitingMember,
ReservationStatus.WAITING));
// then
RestAssured.given().log().all()
.port(port)
.header("Cookie", accessTokenCookie)
.when().delete("/reservations/waiting/{id}", waiting.getId())
.then().log().all()
.statusCode(404);
}
@Test
@DisplayName("관리자 권한이 있으면 전체 예약정보를 조회할 수 있다.")
void readEmptyReservations() {
// given
String accessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234");
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
// when
reservationRepository.save(
new Reservation(LocalDate.now(), reservationTime, theme, member, ReservationStatus.CONFIRMED));
reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member,
ReservationStatus.CONFIRMED));
reservationRepository.save(new Reservation(LocalDate.now().plusDays(2), reservationTime, theme, member,
ReservationStatus.CONFIRMED));
// then
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", accessTokenCookie))
.when().get("/reservations")
.then().log().all()
.statusCode(200)
.body("data.reservations.size()", is(3));
}
@Test
@DisplayName("예약 취소는 관리자만 할 수 있다.")
void canRemoveMyReservation() {
// given
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
String accessTokenCookie = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword());
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Reservation reservation = reservationRepository.save(
new Reservation(LocalDate.now(), reservationTime, theme, member, ReservationStatus.CONFIRMED));
// when & then
RestAssured.given().log().all()
.port(port)
.header("Cookie", accessTokenCookie)
.when().delete("/reservations/" + reservation.getId())
.then().log().all()
.statusCode(302);
}
@Test
@DisplayName("관리자가 대기중인 예약을 거절한다.")
void denyWaiting() {
// given
String adminTokenCookie = getAdminAccessTokenCookieByLogin("admin@email.com", "password");
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member confirmedMember = memberRepository.save(
new Member(null, "name1", "email@email.com", "password", Role.MEMBER));
Member waitingMember = memberRepository.save(
new Member(null, "name1", "email1@email.com", "password", Role.MEMBER));
reservationRepository.save(
new Reservation(LocalDate.now(), reservationTime, theme, confirmedMember, ReservationStatus.CONFIRMED));
Reservation waiting = reservationRepository.save(
new Reservation(LocalDate.now(), reservationTime, theme, waitingMember, ReservationStatus.WAITING));
// when & then
RestAssured.given().log().all()
.port(port)
.header("Cookie", adminTokenCookie)
.when().post("/reservations/waiting/{id}/deny", waiting.getId())
.then().log().all()
.statusCode(204);
}
@Test
@DisplayName("본인의 예약이 아니더라도 관리자 권한이 있으면 예약 정보를 삭제할 수 있다.")
void readReservationsSizeAfterPostAndDelete() {
// given
Member member = memberRepository.save(new Member(null, "name", "admin@admin.com", "password", Role.ADMIN));
String accessTokenCookie = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword());
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member anotherMember = memberRepository.save(
new Member(null, "name", "email@email.com", "password", Role.MEMBER));
Reservation reservation = reservationRepository.save(
new Reservation(LocalDate.now(), reservationTime, theme, anotherMember, ReservationStatus.CONFIRMED));
// when & then
RestAssured.given().log().all()
.port(port)
.header("Cookie", accessTokenCookie)
.when().delete("/reservations/" + reservation.getId())
.then().log().all()
.statusCode(204);
}
@ParameterizedTest
@MethodSource("requestValidateSource")
@DisplayName("예약 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.")
void validateBlankRequest(Map<String, String> invalidRequestBody) {
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.body(invalidRequestBody)
.header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a"))
.when().post("/reservations")
.then().log().all()
.statusCode(400);
}
private static Stream<Map<String, String>> requestValidateSource() {
return Stream.of(
Map.of("timeId", "1",
"themeId", "1"),
Map.of("date", LocalDate.now().plusDays(1L).toString(),
"themeId", "1"),
Map.of("date", LocalDate.now().plusDays(1L).toString(),
"timeId", "1"),
Map.of("date", " ",
"timeId", "1",
"themeId", "1"),
Map.of("date", LocalDate.now().plusDays(1L).toString(),
"timeId", " ",
"themeId", "1"),
Map.of("date", LocalDate.now().plusDays(1L).toString(),
"timeId", "1",
"themeId", " ")
);
}
@Test
@DisplayName("예약 생성 시, 정수 요청 데이터에 문자가 입력되어오면 400 에러를 발생한다.")
void validateRequestDataFormat() {
Map<String, String> invalidTypeRequestBody = Map.of(
"date", LocalDate.now().plusDays(1L).toString(),
"timeId", "1",
"themeId", "한글"
);
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a"))
.body(invalidTypeRequestBody)
.when().post("/reservations")
.then().log().all()
.statusCode(400);
}
@ParameterizedTest
@DisplayName("모든 예약 / 대기 중인 예약 / 현재 로그인된 회원의 예약 및 대기를 조회한다.")
@CsvSource(value = {"/reservations, reservations, 2", "/reservations/waiting, reservations, 1",
"/reservations-mine, myReservationResponses, 3"}, delimiter = ',')
void getAllReservations(String requestURI, String responseFieldName, int expectedSize) {
// given
LocalDate date = LocalDate.now().plusDays(1);
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
ReservationTime time1 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(18, 30)));
ReservationTime time2 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(19, 30)));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.ADMIN));
String accessToken = getAccessTokenCookieByLogin("email@email.com", "password");
// when : 예약은 2개, 예약 대기는 1개 조회되어야 한다.
reservationRepository.save(new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED));
reservationRepository.save(
new Reservation(date, time1, theme, member, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED));
reservationRepository.save(new Reservation(date, time2, theme, member, ReservationStatus.WAITING));
// then
RestAssured.given().log().all()
.port(port)
.header("Cookie", accessToken)
.when().get(requestURI)
.then().log().all()
.statusCode(200)
.body("data." + responseFieldName + ".size()", is(expectedSize));
}
@Test
@DisplayName("예약을 삭제할 때, 승인되었으나 결제 대기중인 예약은 결제 취소 없이 바로 삭제한다.")
void removeNotPaidReservation() {
// given
LocalDate date = LocalDate.now().plusDays(1);
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password");
// when
Reservation saved = reservationRepository.save(new Reservation(date, time, theme,
memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)),
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED));
// then
RestAssured.given().log().all()
.port(port)
.header("Cookie", accessToken)
.when().delete("/reservations/{id}", saved.getId())
.then().log().all()
.statusCode(204);
}
@Test
@DisplayName("이미 결제가 된 예약은 삭제 후 결제 취소를 요청한다.")
void removePaidReservation() {
// given
String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password");
LocalDate date = LocalDate.now().plusDays(1);
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
Reservation saved = reservationRepository.save(
new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED));
PaymentEntity savedPaymentEntity = paymentRepository.save(
new PaymentEntity(null, "pk", "oi", 1000L, saved, OffsetDateTime.now().minusHours(1L)));
// when
when(paymentClient.cancelPayment(any(PaymentCancel.Request.class)))
.thenReturn(new PaymentCancel.Response("pk", "고객 요청", savedPaymentEntity.getTotalAmount(),
OffsetDateTime.now()));
// then
RestAssured.given().log().all()
.port(port)
.header("Cookie", accessToken)
.when().delete("/reservations/{id}", saved.getId())
.then().log().all()
.statusCode(204);
}
@Test
@DisplayName("예약을 추가할 때, 결제 승인 이후에 예외가 발생하면 결제를 취소한 뒤 결제 취소 테이블에 취소 정보를 저장한다.")
void saveReservationWithCancelPayment() {
// given
LocalDateTime localDateTime = LocalDateTime.now().minusHours(1L).withNano(0);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
String accessToken = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword());
// when : 이전 날짜의 예약을 추가하여 결제 승인 이후 DB 저장 과정에서 예외를 발생시킨다.
String paymentKey = "pk";
OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(1L).withNano(0);
OffsetDateTime approvedAt = OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(9));
when(paymentClient.confirmPayment(any(PaymentApprove.Request.class)))
.thenReturn(new PaymentApprove.Response(paymentKey, "oi", approvedAt, 1000L));
when(paymentClient.cancelPayment(any(PaymentCancel.Request.class)))
.thenReturn(new PaymentCancel.Response(paymentKey, "고객 요청", 1000L, canceledAt));
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header("Cookie", accessToken)
.body(new ReservationRequest(date, time.getId(), theme.getId(), "pk", "oi", 1000L, "DEFAULT"))
.when().post("/reservations")
.then().log().all()
.statusCode(400);
// then
CanceledPaymentEntity canceledPayment = canceledPaymentRepository.findByPaymentKey(paymentKey);
assertThat(canceledPayment).isNotNull();
assertThat(canceledPayment.getCanceledAt()).isEqualTo(canceledAt);
assertThat(canceledPayment.getCancelReason()).isEqualTo("고객 요청");
assertThat(canceledPayment.getCancelAmount()).isEqualTo(1000L);
assertThat(canceledPayment.getApprovedAt()).isEqualTo(approvedAt);
}
@DisplayName("테마만을 이용하여 예약을 조회한다.")
@ParameterizedTest(name = "테마 ID={0}로 조회 시 {1}개의 예약이 조회된다.")
@CsvSource(value = {"1/4", "2/3"}, delimiter = '/')
@Sql({"/truncate.sql", "/test_search_data.sql"})
void searchByTheme(String themeId, int expectedCount) {
RestAssured.given().log().all()
.port(port)
.param("themeId", themeId)
.param("memberId", "")
.param("dateFrom", "")
.param("dateTo", "")
.header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password"))
.when().get("/reservations/search")
.then().log().all()
.statusCode(HttpStatus.OK.value())
.body("data.reservations.size()", is(expectedCount));
}
@DisplayName("시작 날짜만을 이용하여 예약을 조회한다.")
@ParameterizedTest(name = "오늘 날짜보다 {0}일 전인 날짜를 시작 날짜로 조회 시 {1}개의 예약이 조회된다.")
@CsvSource(value = {"1/1", "7/7"}, delimiter = '/')
@Sql({"/truncate.sql", "/test_search_data.sql"})
void searchByFromDate(int minusDays, int expectedCount) {
RestAssured.given().log().all()
.port(port)
.param("themeId", "")
.param("memberId", "")
.param("dateFrom", LocalDate.now().minusDays(minusDays).toString())
.param("dateTo", "")
.header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password"))
.when().get("/reservations/search")
.then().log().all()
.statusCode(HttpStatus.OK.value())
.body("data.reservations.size()", is(expectedCount));
}
@DisplayName("종료 날짜만을 이용하여 예약을 조회한다..")
@ParameterizedTest(name = "오늘 날짜보다 {0}일 전인 날짜를 종료 날짜로 조회 시 {1}개의 예약이 조회된다.")
@CsvSource(value = {"1/7", "3/5", "7/1"}, delimiter = '/')
@Sql({"/truncate.sql", "/test_search_data.sql"})
void searchByToDate(int minusDays, int expectedCount) {
RestAssured.given().log().all()
.port(port)
.param("themeId", "")
.param("memberId", "")
.param("dateFrom", "")
.param("dateTo", LocalDate.now().minusDays(minusDays).toString())
.header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password"))
.when().get("/reservations/search")
.then().log().all()
.statusCode(HttpStatus.OK.value())
.body("data.reservations.size()", is(expectedCount));
}
@Test
@DisplayName("예약 대기를 추가한다.")
void addWaiting() {
// given
LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
Member member1 = memberRepository.save(new Member(null, "name1", "email1@email.com", "password", Role.MEMBER));
String accessToken = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword());
reservationRepository.save(new Reservation(date, time, theme, member1, ReservationStatus.CONFIRMED));
// when & then
RestAssured.given().log().all()
.port(port)
.contentType(ContentType.JSON)
.header("Cookie", accessToken)
.body(new WaitingRequest(date, time.getId(), theme.getId()))
.when().post("/reservations/waiting")
.then().log().all()
.statusCode(201)
.body("data.status", is("WAITING"));
}
@Test
@DisplayName("대기중인 예약을 승인한다.")
void approveWaiting() {
// given
LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password");
Reservation waiting = reservationRepository.save(
new Reservation(date, time, theme, member, ReservationStatus.WAITING));
// when
RestAssured.given().log().all()
.port(port)
.header("Cookie", accessToken)
.when().post("/reservations/waiting/{id}/approve", waiting.getId())
.then().log().all()
.statusCode(200);
// then
reservationRepository.findById(waiting.getId())
.ifPresent(r -> assertThat(r.getReservationStatus()).isEqualTo(
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED));
}
private String getAccessTokenCookieByLogin(final String email, final String password) {
Map<String, String> loginParams = Map.of(
"email", email,
"password", password
);
String accessToken = RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.body(loginParams)
.when().post("/login")
.then().log().all().extract().cookie("accessToken");
return "accessToken=" + accessToken;
}
@Test
@DisplayName("관리자가 직접 예약을 추가한다.")
void addReservationByAdmin() {
// given
LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
String adminAccessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password");
// when & then
RestAssured.given().log().all()
.port(port)
.contentType(ContentType.JSON)
.header("Cookie", adminAccessToken)
.body(new AdminReservationRequest(date, time.getId(), theme.getId(), member.getId()))
.when().post("/reservations/admin")
.then().log().all()
.statusCode(201);
}
private String getAdminAccessTokenCookieByLogin(final String email, final String password) {
memberRepository.save(new Member(null, "이름", email, password, Role.ADMIN));
Map<String, String> loginParams = Map.of(
"email", email,
"password", password
);
String accessToken = RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.body(loginParams)
.when().post("/login")
.then().log().all().extract().cookie("accessToken");
return "accessToken=" + accessToken;
}
}

View File

@ -1,247 +0,0 @@
package roomescape.reservation.controller;
import static org.hamcrest.Matchers.*;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.http.Header;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.domain.repository.ReservationRepository;
import roomescape.reservation.domain.repository.ReservationTimeRepository;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.infrastructure.persistence.ThemeRepository;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
public class ReservationTimeControllerTest {
@Autowired
private ReservationTimeRepository reservationTimeRepository;
@Autowired
private ThemeRepository themeRepository;
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private MemberRepository memberRepository;
@LocalServerPort
private int port;
private final Map<String, String> params = Map.of(
"startAt", "17:00"
);
@Test
@DisplayName("처음으로 등록하는 시간의 id는 1이다.")
void firstPost() {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.body(params)
.when().post("/times")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/times/1");
}
@Test
@DisplayName("아무 시간도 등록 하지 않은 경우, 시간 목록 조회 결과 개수는 0개이다.")
void readEmptyTimes() {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.when().get("/times")
.then().log().all()
.statusCode(200)
.body("data.times.size()", is(0));
}
@Test
@DisplayName("하나의 시간만 등록한 경우, 시간 목록 조회 결과 개수는 1개이다.")
void readTimesSizeAfterFirstPost() {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.body(params)
.when().post("/times")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/times/1");
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.when().get("/times")
.then().log().all()
.statusCode(200)
.body("data.times.size()", is(1));
}
@Test
@DisplayName("하나의 시간만 등록한 경우, 시간 삭제 뒤 시간 목록 조회 결과 개수는 0개이다.")
void readTimesSizeAfterPostAndDelete() {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.body(params)
.when().post("/times")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/times/1");
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.when().delete("/times/1")
.then().log().all()
.statusCode(204);
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.when().get("/times")
.then().log().all()
.statusCode(200)
.body("data.times.size()", is(0));
}
@ParameterizedTest
@MethodSource("validateRequestDataFormatSource")
@DisplayName("예약 시간 생성 시, 시간 요청 데이터에 시간 포맷이 아닌 값이 입력되어오면 400 에러를 발생한다.")
void validateRequestDataFormat(Map<String, String> request) {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.body(request)
.when().post("/times")
.then().log().all()
.statusCode(400);
}
static Stream<Map<String, String>> validateRequestDataFormatSource() {
return Stream.of(
Map.of(
"startAt", "24:59"
),
Map.of(
"startAt", "hihi")
);
}
@ParameterizedTest
@MethodSource("validateBlankRequestSource")
@DisplayName("예약 시간 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.")
void validateBlankRequest(Map<String, String> request) {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.body(request)
.when().post("/times")
.then().log().all()
.statusCode(400);
}
static Stream<Map<String, String>> validateBlankRequestSource() {
return Stream.of(
Map.of(
),
Map.of(
"startAt", ""
),
Map.of(
"startAt", " "
)
);
}
private String getAdminAccessTokenCookieByLogin(String email, String password) {
memberRepository.save(new Member(null, "이름", email, password, Role.ADMIN));
Map<String, String> loginParams = Map.of(
"email", email,
"password", password
);
String accessToken = RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.body(loginParams)
.when().post("/login")
.then().log().all().extract().cookie("accessToken");
return "accessToken=" + accessToken;
}
@Test
@DisplayName("특정 날짜의 특정 테마 예약 현황을 조회한다.")
void readReservationByDateAndThemeId() {
// given
LocalDate today = LocalDate.now();
ReservationTime reservationTime1 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 0)));
ReservationTime reservationTime2 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30)));
ReservationTime reservationTime3 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(18, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명1", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
reservationRepository.save(
new Reservation(today.plusDays(1), reservationTime1, theme, member, ReservationStatus.CONFIRMED));
reservationRepository.save(
new Reservation(today.plusDays(1), reservationTime2, theme, member, ReservationStatus.CONFIRMED));
reservationRepository.save(
new Reservation(today.plusDays(1), reservationTime3, theme, member, ReservationStatus.CONFIRMED));
// when & then
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a"))
.when().get("/times/filter?date={date}&themeId={themeId}", today.plusDays(1).toString(), theme.getId())
.then().log().all()
.statusCode(200)
.body("data.reservationTimes.size()", is(3));
}
}

View File

@ -1,55 +0,0 @@
package roomescape.reservation.domain;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.stream.Stream;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.common.exception.RoomescapeException;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
public class ReservationTest {
@ParameterizedTest
@MethodSource("validateConstructorParameterBlankSource")
@DisplayName("객체 생성 시, null 또는 공백이 존재하면 예외를 발생한다.")
void validateConstructorParameterBlank(LocalDate date, ReservationTime reservationTime, ThemeEntity theme,
Member member) {
// when & then
Assertions.assertThatThrownBy(
() -> new Reservation(date, reservationTime, theme, member, ReservationStatus.CONFIRMED))
.isInstanceOf(RoomescapeException.class);
}
static Stream<Arguments> validateConstructorParameterBlankSource() {
return Stream.of(
Arguments.of(null,
new ReservationTime(LocalTime.now().plusHours(1)),
new ThemeEntity(null, "테마명", "설명", "썸네일URI"),
new Member(null, "name", "email@email.com", "password", Role.MEMBER)),
Arguments.of(
LocalDate.now(),
null,
new ThemeEntity(null, "테마명", "설명", "썸네일URI"),
new Member(null, "name", "email@email.com", "password", Role.MEMBER)),
Arguments.of(
LocalDate.now(),
new ReservationTime(LocalTime.now().plusHours(1)),
null,
new Member(null, "name", "email@email.com", "password", Role.MEMBER)),
Arguments.of(
LocalDate.now(),
new ReservationTime(LocalTime.now().plusHours(1)),
new ThemeEntity(null, "테마명", "설명", "썸네일URI"),
null)
);
}
}

View File

@ -1,19 +0,0 @@
package roomescape.reservation.domain;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import roomescape.common.exception.RoomescapeException;
class ReservationTimeTest {
@Test
@DisplayName("객체 생성 시, null이 존재하면 예외를 발생한다.")
void validateConstructorParameterNull() {
// when & then
Assertions.assertThatThrownBy(() -> new ReservationTime(null))
.isInstanceOf(RoomescapeException.class);
}
}

View File

@ -1,175 +0,0 @@
package roomescape.reservation.domain.repository;
import static org.assertj.core.api.Assertions.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.data.jpa.domain.Specification;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.infrastructure.persistence.ThemeRepository;
@DataJpaTest
class ReservationSearchSpecificationTest {
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private ReservationTimeRepository timeRepository;
@Autowired
private ThemeRepository themeRepository;
@Autowired
private MemberRepository memberRepository;
/**
* 시간은 모두 현재 시간(LocalTime.now()), 테마, 회원은 동일 확정된 예약은 오늘, 결제 대기인 예약은 어제, 대기 상태인 예약은 내일
*/
// 현재 시간으로 확정 예약
private Reservation reservation1;
// 확정되었으나 결제 대기인 하루 예약
private Reservation reservation2;
// 대기 상태인 내일 예약
private Reservation reservation3;
@BeforeEach
void setUp() {
LocalDateTime dateTime = LocalDateTime.now();
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
ReservationTime time = timeRepository.save(new ReservationTime(dateTime.toLocalTime()));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "name", "description", "thumbnail"));
reservation1 = reservationRepository.save(
new Reservation(dateTime.toLocalDate(), time, theme, member, ReservationStatus.CONFIRMED));
reservation2 = reservationRepository.save(
new Reservation(dateTime.toLocalDate().minusDays(1), time, theme, member,
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED));
reservation3 = reservationRepository.save(
new Reservation(dateTime.toLocalDate().plusDays(1), time, theme, member, ReservationStatus.WAITING));
}
@Test
@DisplayName("동일한 테마의 예약을 찾는다.")
void searchByThemeId() {
// given
Long themeId = reservation1.getTheme().getId();
Specification<Reservation> spec = new ReservationSearchSpecification().sameThemeId(themeId).build();
// when
List<Reservation> found = reservationRepository.findAll(spec);
// then
assertThat(found).containsExactly(reservation1, reservation2, reservation3);
}
@Test
@DisplayName("동일한 회원의 예약을 찾는다.")
void searchByMemberId() {
// given
Long memberId = reservation1.getMember().getId();
Specification<Reservation> spec = new ReservationSearchSpecification().sameMemberId(memberId).build();
// when
List<Reservation> found = reservationRepository.findAll(spec);
// then
assertThat(found).containsExactly(reservation1, reservation2, reservation3);
}
@Test
@DisplayName("동일한 시간의 예약을 찾는다.")
void searchByTimeId() {
// given
Long timeId = reservation1.getReservationTime().getId();
Specification<Reservation> spec = new ReservationSearchSpecification().sameTimeId(timeId).build();
// when
List<Reservation> found = reservationRepository.findAll(spec);
// then
assertThat(found).containsExactly(reservation1, reservation2, reservation3);
}
@Test
@DisplayName("동일한 날짜의 예약을 찾는다.")
void searchByDate() {
// given
LocalDate date = reservation1.getDate();
Specification<Reservation> spec = new ReservationSearchSpecification().sameDate(date).build();
// when
List<Reservation> found = reservationRepository.findAll(spec);
// then
assertThat(found).containsExactly(reservation1);
}
@Test
@DisplayName("확정 상태인 예약을 찾는다.")
void searchConfirmedReservation() {
// given
Specification<Reservation> spec = new ReservationSearchSpecification().confirmed().build();
// when
List<Reservation> found = reservationRepository.findAll(spec);
// then
assertThat(found).containsExactly(reservation1, reservation2);
}
@Test
@DisplayName("대기 중인 예약을 찾는다.")
void searchWaitingReservation() {
// given
Specification<Reservation> spec = new ReservationSearchSpecification().waiting().build();
// when
List<Reservation> found = reservationRepository.findAll(spec);
// then
assertThat(found).containsExactly(reservation3);
}
@Test
@DisplayName("특정 날짜 이후의 예약을 찾는다.")
void searchDateStartFrom() {
// given : 어제 이후의 예약을 조회하면, 모든 예약이 조회되어야 한다.
LocalDate date = LocalDate.now().minusDays(1L);
Specification<Reservation> spec = new ReservationSearchSpecification().dateStartFrom(date).build();
// when
List<Reservation> found = reservationRepository.findAll(spec);
// then
assertThat(found).containsExactly(reservation1, reservation2, reservation3);
}
@Test
@DisplayName("특정 날짜 이전의 예약을 찾는다.")
void searchDateEndAt() {
// given : 내일 이전의 예약을 조회하면, 모든 예약이 조회되어야 한다.
LocalDate date = LocalDate.now().plusDays(1L);
Specification<Reservation> spec = new ReservationSearchSpecification().dateEndAt(date).build();
// when
List<Reservation> found = reservationRepository.findAll(spec);
// then
assertThat(found).containsExactly(reservation1, reservation2, reservation3);
}
}

View File

@ -0,0 +1,200 @@
package roomescape.reservation.infrastructure.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.data.repository.findByIdOrNull
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.reservation.web.MyReservationResponse
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.util.PaymentFixture
import roomescape.util.ReservationFixture
import roomescape.util.ReservationTimeFixture
import roomescape.util.ThemeFixture
@DataJpaTest
class ReservationRepositoryTest(
val entityManager: EntityManager,
val reservationRepository: ReservationRepository,
) : FunSpec() {
init {
context("findByReservationTime") {
val time = ReservationTimeFixture.create()
beforeTest {
listOf(
ReservationFixture.create(reservationTime = time),
ReservationFixture.create(reservationTime = ReservationTimeFixture.create(
startAt = time.startAt.plusSeconds(1)
))
).forEach {
persistReservation(it)
}
entityManager.flush()
entityManager.clear()
}
test("입력된 시간과 일치하는 예약을 반환한다.") {
assertSoftly(reservationRepository.findByReservationTime(time)) {
it shouldHaveSize 1
assertSoftly(it.first().reservationTime.startAt) { result ->
result.hour shouldBe time.startAt.hour
result.minute shouldBe time.startAt.minute
}
}
}
}
context("findByDateAndThemeId") {
val date = ReservationFixture.create().date
lateinit var theme1: ThemeEntity
lateinit var theme2: ThemeEntity
beforeTest {
theme1 = ThemeFixture.create(name = "theme1").also {
entityManager.persist(it)
}
theme2 = ThemeFixture.create(name = "theme2").also {
entityManager.persist(it)
}
listOf(
ReservationFixture.create(date = date, theme = theme1),
ReservationFixture.create(date = date.plusDays(1), theme = theme1),
ReservationFixture.create(date = date, theme = theme2),
).forEach {
entityManager.persist(it.reservationTime)
entityManager.persist(it.member)
entityManager.persist(it)
}
}
test("입력된 날짜와 테마 ID에 해당하는 예약을 반환한다.") {
assertSoftly(reservationRepository.findByDateAndThemeId(date, theme1.id!!)) {
it shouldHaveSize 1
it.first().theme shouldBe theme1
}
}
}
context("updateStatusByReservationId") {
lateinit var reservation: ReservationEntity
beforeTest {
reservation = ReservationFixture.create().also {
persistReservation(it)
}
entityManager.flush()
entityManager.clear()
}
test("예약 상태를 업데이트한다.") {
ReservationStatus.entries.forEach {
val reservationId = reservation.id!!
val updatedRows = reservationRepository.updateStatusByReservationId(reservationId, it)
updatedRows shouldBe 1
entityManager.flush()
entityManager.clear()
reservationRepository.findByIdOrNull(reservationId)?.also { updated ->
updated.reservationStatus shouldBe it
}
}
}
}
context("isExistConfirmedReservation") {
lateinit var waiting: ReservationEntity
lateinit var confirmed: ReservationEntity
lateinit var confirmedPaymentRequired: ReservationEntity
beforeTest {
waiting = ReservationFixture.create(status = ReservationStatus.WAITING).also {
persistReservation(it)
}
confirmed = ReservationFixture.create(status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED).also {
persistReservation(it)
}
confirmedPaymentRequired = ReservationFixture.create(status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED).also {
persistReservation(it)
}
entityManager.flush()
entityManager.clear()
}
test("예약이 없으면 false를 반환한다.") {
val maxId: Long = listOf(waiting, confirmed, confirmedPaymentRequired)
.maxOfOrNull { it.id ?: 0L } ?: 0L
reservationRepository.isExistConfirmedReservation(maxId + 1L) shouldBe false
}
test("예약이 대기중이면 false를 반환한다.") {
reservationRepository.isExistConfirmedReservation(waiting.id!!) shouldBe false
}
test("예약이 결제 완료 상태이면 true를 반환한다.") {
reservationRepository.isExistConfirmedReservation(confirmed.id!!) shouldBe true
}
test("예약이 결제 대기 상태이면 true를 반환한다.") {
reservationRepository.isExistConfirmedReservation(confirmedPaymentRequired.id!!) shouldBe true
}
}
context("findMyReservations") {
lateinit var reservation: ReservationEntity
beforeTest {
reservation = ReservationFixture.create()
persistReservation(reservation)
}
test("결제 정보를 포함한 회원의 예약 목록을 반환한다.") {
val payment: PaymentEntity = PaymentFixture.create(
reservation = reservation
).also {
entityManager.persist(it)
entityManager.flush()
entityManager.clear()
}
val result: List<MyReservationResponse> = reservationRepository.findMyReservations(reservation.member.id!!)
result shouldHaveSize 1
assertSoftly(result.first()) {
it.id shouldBe reservation.id
it.paymentKey shouldBe payment.paymentKey
it.amount shouldBe payment.totalAmount
}
}
test("결제 정보가 없다면 paymentKey와 amount는 null로 반환한다.") {
val result: List<MyReservationResponse> = reservationRepository.findMyReservations(reservation.member.id!!)
result shouldHaveSize 1
assertSoftly(result.first()) {
it.id shouldBe reservation.id
it.paymentKey shouldBe null
it.amount shouldBe null
}
}
}
}
fun persistReservation(reservation: ReservationEntity) {
entityManager.persist(reservation.reservationTime)
entityManager.persist(reservation.theme)
entityManager.persist(reservation.member)
entityManager.persist(reservation)
}
}

View File

@ -0,0 +1,179 @@
package roomescape.reservation.infrastructure.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.ReservationTimeFixture
import roomescape.util.ThemeFixture
import java.time.LocalDate
@DataJpaTest
class ReservationSearchSpecificationTest(
val entityManager: EntityManager,
val reservationRepository: ReservationRepository
) : StringSpec() {
init {
lateinit var confirmedNow: ReservationEntity
lateinit var confirmedNotPaidYesterday: ReservationEntity
lateinit var waitingTomorrow: ReservationEntity
lateinit var member: MemberEntity
lateinit var reservationTime: ReservationTimeEntity
lateinit var theme: ThemeEntity
"동일한 테마의 예약을 조회한다" {
val spec = ReservationSearchSpecification()
.sameThemeId(theme.id)
.build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize 3
this shouldContainExactly listOf(confirmedNow, confirmedNotPaidYesterday, waitingTomorrow)
}
}
"동일한 회원의 예약을 조회한다" {
val spec = ReservationSearchSpecification()
.sameMemberId(member.id)
.build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize 3
this shouldContainExactly listOf(confirmedNow, confirmedNotPaidYesterday, waitingTomorrow)
}
}
"동일한 예약 시간의 예약을 조회한다" {
val spec = ReservationSearchSpecification()
.sameTimeId(reservationTime.id)
.build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize 3
this shouldContainExactly listOf(confirmedNow, confirmedNotPaidYesterday, waitingTomorrow)
}
}
"동일한 날짜의 예약을 조회한다" {
val spec = ReservationSearchSpecification()
.sameDate(LocalDate.now())
.build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize 1
this shouldContainExactly listOf(confirmedNow)
}
}
"확정 상태인 예약을 조회한다" {
val spec = ReservationSearchSpecification()
.confirmed()
.build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize 2
this shouldContainExactly listOf(confirmedNow, confirmedNotPaidYesterday)
}
}
"대기 상태인 예약을 조회한다" {
val spec = ReservationSearchSpecification()
.waiting()
.build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize 1
this shouldContainExactly listOf(waitingTomorrow)
}
}
"예약 날짜가 오늘 이후인 예약을 조회한다" {
val spec = ReservationSearchSpecification()
.dateStartFrom(LocalDate.now())
.build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize 2
this shouldContainExactly listOf(confirmedNow, waitingTomorrow)
}
}
"예약 날짜가 내일 이전인 예약을 조회한다" {
val spec = ReservationSearchSpecification()
.dateEndAt(LocalDate.now().plusDays(1))
.build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize 3
this shouldContainExactly listOf(confirmedNow, confirmedNotPaidYesterday, waitingTomorrow)
}
}
beforeTest {
member = MemberFixture.create().also {
entityManager.persist(it)
}
reservationTime = ReservationTimeFixture.create().also {
entityManager.persist(it)
}
theme = ThemeFixture.create().also {
entityManager.persist(it)
}
confirmedNow = ReservationFixture.create(
reservationTime = reservationTime,
member = member,
theme = theme,
date = LocalDate.now(),
status = ReservationStatus.CONFIRMED
).also {
entityManager.persist(it)
}
confirmedNotPaidYesterday = ReservationFixture.create(
reservationTime = reservationTime,
member = member,
theme = theme,
date = LocalDate.now().minusDays(1),
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
).also {
entityManager.persist(it)
}
waitingTomorrow = ReservationFixture.create(
reservationTime = reservationTime,
member = member,
theme = theme,
date = LocalDate.now().plusDays(1),
status = ReservationStatus.WAITING
).also {
entityManager.persist(it)
}
entityManager.flush()
}
}
}

View File

@ -0,0 +1,33 @@
package roomescape.reservation.infrastructure.persistence
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.util.ReservationTimeFixture
import java.time.LocalTime
@DataJpaTest
class ReservationTimeRepositoryTest(
val entityManager: EntityManager,
val reservationTimeRepository: ReservationTimeRepository,
) : FunSpec({
context("existsByStartAt") {
val startAt = LocalTime.of(10, 0)
beforeTest {
entityManager.persist(ReservationTimeFixture.create(startAt = startAt))
entityManager.flush()
entityManager.clear()
}
test("동일한 시간이 있으면 true 반환") {
reservationTimeRepository.existsByStartAt(startAt) shouldBe true
}
test("동일한 시간이 없으면 false 반환") {
reservationTimeRepository.existsByStartAt(startAt.plusSeconds(1)) shouldBe false
}
}
})

View File

@ -1,219 +0,0 @@
package roomescape.reservation.service;
import static org.assertj.core.api.Assertions.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
import roomescape.member.business.MemberService;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.domain.repository.ReservationRepository;
import roomescape.reservation.domain.repository.ReservationTimeRepository;
import roomescape.reservation.dto.request.ReservationRequest;
import roomescape.reservation.dto.request.WaitingRequest;
import roomescape.reservation.dto.response.ReservationResponse;
import roomescape.common.exception.RoomescapeException;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.infrastructure.persistence.ThemeRepository;
import roomescape.theme.business.ThemeService;
@SpringBootTest
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Import({ReservationService.class, MemberService.class, ReservationTimeService.class, ThemeService.class})
class ReservationServiceTest {
@Autowired
ReservationTimeRepository reservationTimeRepository;
@Autowired
ReservationRepository reservationRepository;
@Autowired
ThemeRepository themeRepository;
@Autowired
MemberRepository memberRepository;
@Autowired
private ReservationService reservationService;
@Test
@DisplayName("예약을 추가할때 이미 예약이 존재하면 예외가 발생한다.")
void reservationAlreadyExistFail() {
// given
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member1 = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
Member member2 = memberRepository.save(new Member(null, "name2", "email2@email.com", "password", Role.MEMBER));
LocalDate date = LocalDate.now().plusDays(1L);
// when
reservationService.addReservation(
new ReservationRequest(date, reservationTime.getId(), theme.getId(), "paymentKey", "orderId",
1000L, "paymentType"), member2.getId());
// then
assertThatThrownBy(() -> reservationService.addReservation(
new ReservationRequest(date, reservationTime.getId(), theme.getId(), "paymentKey", "orderId",
1000L, "paymentType"), member1.getId()))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("이미 예약한 멤버가 같은 테마에 대기를 신청하면 예외가 발생한다.")
void requestWaitWhenAlreadyReserveFail() {
// given
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
LocalDate date = LocalDate.now().plusDays(1L);
// when
reservationService.addReservation(
new ReservationRequest(date, reservationTime.getId(), theme.getId(), "paymentKey", "orderId",
1000L, "paymentType"), member.getId());
// then
assertThatThrownBy(() -> reservationService.addWaiting(
new WaitingRequest(date, reservationTime.getId(), theme.getId()), member.getId()))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("예약 대기를 두 번 이상 요청하면 예외가 발생한다.")
void requestWaitTwiceFail() {
// given
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
Member member1 = memberRepository.save(new Member(null, "name1", "email1@email.com", "password", Role.MEMBER));
LocalDate date = LocalDate.now().plusDays(1L);
// when
reservationService.addReservation(
new ReservationRequest(date, reservationTime.getId(), theme.getId(), "paymentKey", "orderId",
1000L, "paymentType"), member.getId());
reservationService.addWaiting(
new WaitingRequest(date, reservationTime.getId(), theme.getId()), member1.getId());
// then
assertThatThrownBy(() -> reservationService.addWaiting(
new WaitingRequest(date, reservationTime.getId(), theme.getId()), member1.getId()))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("이미 지난 날짜로 예약을 생성하면 예외가 발생한다.")
void beforeDateReservationFail() {
// given
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
LocalDate beforeDate = LocalDate.now().minusDays(1L);
// when & then
assertThatThrownBy(() -> reservationService.addReservation(
new ReservationRequest(beforeDate, reservationTime.getId(), theme.getId(), "paymentKey", "orderId",
1000L, "paymentType"), member.getId()))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("현재 날짜가 예약 당일이지만, 이미 지난 시간으로 예약을 생성하면 예외가 발생한다.")
void beforeTimeReservationFail() {
// given
LocalDateTime beforeTime = LocalDateTime.now().minusHours(1L).withNano(0);
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(beforeTime.toLocalTime()));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
// when & then
assertThatThrownBy(() -> reservationService.addReservation(
new ReservationRequest(beforeTime.toLocalDate(), reservationTime.getId(), theme.getId(), "paymentKey",
"orderId", 1000L, "paymentType"), member.getId()))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("존재하지 않는 회원이 예약을 생성하려고 하면 예외가 발생한다.")
void notExistMemberReservationFail() {
// given
LocalDateTime beforeTime = LocalDateTime.now().minusDays(1L).withNano(0);
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(beforeTime.toLocalTime()));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Long NotExistMemberId = 1L;
// when & then
assertThatThrownBy(() -> reservationService.addReservation(
new ReservationRequest(beforeTime.toLocalDate(), reservationTime.getId(), theme.getId(), "paymentKey",
"orderId", 1000L, "paymentType"),
NotExistMemberId))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("예약을 조회할 때 종료 날짜가 시작 날짜 이전이면 예외가 발생한다.")
void invalidDateRange() {
// given
LocalDate dateFrom = LocalDate.now().plusDays(1);
LocalDate dateTo = LocalDate.now();
// when & then
assertThatThrownBy(() -> reservationService.findFilteredReservations(null, null, dateFrom, dateTo))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("대기중인 예약을 승인할 때, 기존에 예약이 존재하면 예외가 발생한다.")
void confirmWaitingWhenReservationExist() {
// given
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member admin = memberRepository.save(new Member(null, "admin", "admin@email.com", "password", Role.ADMIN));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
Member member1 = memberRepository.save(new Member(null, "name1", "email1@email.com", "password", Role.MEMBER));
reservationService.addReservation(
new ReservationRequest(LocalDate.now().plusDays(1L), reservationTime.getId(), theme.getId(),
"paymentKey", "orderId",
1000L, "paymentType"), member.getId());
ReservationResponse waiting = reservationService.addWaiting(
new WaitingRequest(LocalDate.now().plusDays(1L), reservationTime.getId(), theme.getId()),
member1.getId());
// when & then
assertThatThrownBy(() -> reservationService.approveWaiting(waiting.id(), admin.getId()))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("대기중인 예약을 확정한다.")
void approveWaiting() {
// given
ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member admin = memberRepository.save(new Member(null, "admin", "admin@email.com", "password", Role.ADMIN));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
// when
ReservationResponse waiting = reservationService.addWaiting(
new WaitingRequest(LocalDate.now().plusDays(1L), reservationTime.getId(), theme.getId()),
member.getId());
reservationService.approveWaiting(waiting.id(), admin.getId());
// then
Reservation confirmed = reservationRepository.findById(waiting.id()).get();
assertThat(confirmed.getReservationStatus()).isEqualTo(ReservationStatus.CONFIRMED_PAYMENT_REQUIRED);
}
}

View File

@ -1,88 +0,0 @@
package roomescape.reservation.service;
import static org.assertj.core.api.Assertions.*;
import java.time.LocalDateTime;
import java.time.LocalTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.domain.repository.ReservationRepository;
import roomescape.reservation.domain.repository.ReservationTimeRepository;
import roomescape.reservation.dto.request.ReservationTimeRequest;
import roomescape.common.exception.RoomescapeException;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.infrastructure.persistence.ThemeRepository;
@SpringBootTest
@Import(ReservationTimeService.class)
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
class ReservationTimeServiceTest {
@Autowired
private ReservationTimeService reservationTimeService;
@Autowired
private ReservationTimeRepository reservationTimeRepository;
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private ThemeRepository themeRepository;
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("중복된 예약 시간을 등록하는 경우 예외가 발생한다.")
void duplicateTimeFail() {
// given
reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30)));
// when & then
assertThatThrownBy(() -> reservationTimeService.addTime(new ReservationTimeRequest(LocalTime.of(12, 30))))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("존재하지 않는 ID로 시간을 조회하면 예외가 발생한다.")
void findTimeByIdFail() {
// given
ReservationTime saved = reservationTimeRepository.save(new ReservationTime(LocalTime.of(12, 30)));
// when
Long invalidTimeId = saved.getId() + 1;
// when & then
assertThatThrownBy(() -> reservationTimeService.findTimeById(invalidTimeId))
.isInstanceOf(RoomescapeException.class);
}
@Test
@DisplayName("삭제하려는 시간에 예약이 존재하면 예외를 발생한다.")
void usingTimeDeleteFail() {
// given
LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0);
ReservationTime reservationTime = reservationTimeRepository.save(
new ReservationTime(localDateTime.toLocalTime()));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명", "설명", "썸네일URL"));
Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER));
// when
reservationRepository.save(new Reservation(localDateTime.toLocalDate(), reservationTime, theme, member,
ReservationStatus.CONFIRMED));
// then
assertThatThrownBy(() -> reservationTimeService.removeTimeById(reservationTime.getId()))
.isInstanceOf(RoomescapeException.class);
}
}

View File

@ -1,162 +0,0 @@
package roomescape.reservation.service;
import static org.assertj.core.api.Assertions.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository;
import roomescape.payment.infrastructure.persistence.PaymentEntity;
import roomescape.payment.infrastructure.persistence.PaymentRepository;
import roomescape.payment.web.PaymentApprove;
import roomescape.payment.web.PaymentCancel;
import roomescape.reservation.domain.Reservation;
import roomescape.reservation.domain.ReservationStatus;
import roomescape.reservation.domain.ReservationTime;
import roomescape.reservation.domain.repository.ReservationRepository;
import roomescape.reservation.domain.repository.ReservationTimeRepository;
import roomescape.reservation.dto.request.ReservationRequest;
import roomescape.reservation.dto.response.ReservationResponse;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.infrastructure.persistence.ThemeRepository;
@SpringBootTest
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
class ReservationWithPaymentServiceTest {
@Autowired
private ReservationWithPaymentService reservationWithPaymentService;
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private MemberRepository memberRepository;
@Autowired
private ReservationTimeRepository reservationTimeRepository;
@Autowired
private ThemeRepository themeRepository;
@Autowired
private PaymentRepository paymentRepository;
@Autowired
private CanceledPaymentRepository canceledPaymentRepository;
@Test
@DisplayName("예약과 결제 정보를 추가한다.")
void addReservationWithPayment() {
// given
PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id",
OffsetDateTime.now(), 10000L);
LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
Member member = memberRepository.save(new Member(null, "member", "email@email.com", "password", Role.MEMBER));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "name", "desc", "thumbnail"));
ReservationRequest reservationRequest = new ReservationRequest(date, time.getId(), theme.getId(), "payment-key",
"order-id", 10000L, "NORMAL");
// when
ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment(
reservationRequest, paymentInfo, member.getId());
// then
reservationRepository.findById(reservationResponse.id())
.ifPresent(reservation -> {
assertThat(reservation.getMember().getId()).isEqualTo(member.getId());
assertThat(reservation.getTheme().getId()).isEqualTo(theme.getId());
assertThat(reservation.getDate()).isEqualTo(date);
assertThat(reservation.getReservationTime().getId()).isEqualTo(time.getId());
assertThat(reservation.getReservationStatus()).isEqualTo(ReservationStatus.CONFIRMED);
});
PaymentEntity payment = paymentRepository.findByPaymentKey("payment-key");
assertThat(payment).isNotNull();
assertThat(payment.getReservation().getId()).isEqualTo(reservationResponse.id());
assertThat(payment.getPaymentKey()).isEqualTo("payment-key");
assertThat(payment.getOrderId()).isEqualTo("order-id");
assertThat(payment.getTotalAmount()).isEqualTo(10000L);
}
@Test
@DisplayName("예약 ID를 이용하여 예약과 결제 정보를 제거하고, 결제 취소 정보를 저장한다.")
void removeReservationWithPayment() {
// given
PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id",
OffsetDateTime.now(), 10000L);
LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
Member member = memberRepository.save(new Member(null, "member", "admin@email.com", "password", Role.ADMIN));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "name", "desc", "thumbnail"));
ReservationRequest reservationRequest = new ReservationRequest(date, time.getId(), theme.getId(), "payment-key",
"order-id", 10000L, "NORMAL");
ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment(
reservationRequest, paymentInfo, member.getId());
// when
PaymentCancel.Request paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment(
reservationResponse.id(), member.getId());
// then
assertThat(paymentCancelRequest.cancelReason).isEqualTo("고객 요청");
assertThat(reservationRepository.findById(reservationResponse.id())).isEmpty();
assertThat(paymentRepository.findByPaymentKey("payment-key")).isNull();
assertThat(canceledPaymentRepository.findByPaymentKey("payment-key")).isNotNull();
}
@Test
@DisplayName("결제 정보가 없으면 True를 반환한다.")
void isNotPaidReservation() {
// given
PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id",
OffsetDateTime.now(), 10000L);
LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
Member member = memberRepository.save(new Member(null, "member", "admin@email.com", "password", Role.ADMIN));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "name", "desc", "thumbnail"));
Reservation saved = reservationRepository.save(
new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED));
// when
boolean result = reservationWithPaymentService.isNotPaidReservation(saved.getId());
// then
assertThat(result).isTrue();
}
@Test
@DisplayName("결제 정보가 있으면 False를 반환한다.")
void isPaidReservation() {
// given
PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id",
OffsetDateTime.now(), 10000L);
LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0);
LocalDate date = localDateTime.toLocalDate();
ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime()));
Member member = memberRepository.save(new Member(null, "member", "admin@email.com", "password", Role.ADMIN));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "name", "desc", "thumbnail"));
ReservationRequest reservationRequest = new ReservationRequest(date, time.getId(), theme.getId(), "payment-key",
"order-id", 10000L, "NORMAL");
ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment(
reservationRequest, paymentInfo, member.getId());
// when
boolean result = reservationWithPaymentService.isNotPaidReservation(reservationResponse.id());
// then
assertThat(result).isFalse();
}
}

View File

@ -0,0 +1,792 @@
package roomescape.reservation.web
import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.restassured.module.kotlin.extensions.Given
import io.restassured.module.kotlin.extensions.Then
import io.restassured.module.kotlin.extensions.When
import jakarta.persistence.EntityManager
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.transaction.support.TransactionTemplate
import roomescape.auth.web.support.AdminInterceptor
import roomescape.auth.web.support.LoginInterceptor
import roomescape.auth.web.support.MemberIdResolver
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role
import roomescape.payment.infrastructure.client.TossPaymentClient
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.infrastructure.persistence.ReservationTimeEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.util.*
import java.time.LocalDate
import java.time.LocalTime
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ReservationControllerTest(
@LocalServerPort val port: Int,
val entityManager: EntityManager,
val transactionTemplate: TransactionTemplate
) : FunSpec({
extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_EACH_TEST))
}) {
@MockkBean
lateinit var paymentClient: TossPaymentClient
@SpykBean
lateinit var loginInterceptor: LoginInterceptor
@SpykBean
lateinit var adminInterceptor: AdminInterceptor
@SpykBean
lateinit var memberIdResolver: MemberIdResolver
init {
context("POST /reservations") {
lateinit var member: MemberEntity
beforeTest {
member = login(MemberFixture.create(role = Role.MEMBER))
}
test("정상 응답") {
val reservationRequest = createRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse().copy(
paymentKey = reservationRequest.paymentKey,
orderId = reservationRequest.orderId,
totalAmount = reservationRequest.amount,
)
every {
paymentClient.confirmPayment(any())
} returns paymentApproveResponse
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(reservationRequest)
}.When {
post("/reservations")
}.Then {
log().all()
statusCode(201)
body("data.date", equalTo(reservationRequest.date.toString()))
body("data.status", equalTo(ReservationStatus.CONFIRMED.name))
}
}
test("결제 과정에서 발생하는 에러는 그대로 응답") {
val reservationRequest = createRequest()
val paymentException = RoomescapeException(
ErrorType.PAYMENT_SERVER_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR
)
every {
paymentClient.confirmPayment(any())
} throws paymentException
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(reservationRequest)
}.When {
post("/reservations")
}.Then {
log().all()
statusCode(paymentException.httpStatus.value())
body("errorType", equalTo(paymentException.errorType.name))
}
}
test("결제 완료 후 예약 / 결제 정보 저장 과정에서 에러 발생시 결제 취소 후 에러 응답") {
val reservationRequest = createRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse().copy(
paymentKey = reservationRequest.paymentKey,
orderId = reservationRequest.orderId,
totalAmount = reservationRequest.amount,
)
every {
paymentClient.confirmPayment(any())
} returns paymentApproveResponse
// 예약 저장 과정에서 테마가 없는 예외
val invalidRequest = reservationRequest.copy(themeId = reservationRequest.themeId + 1)
val expectedException = RoomescapeException(ErrorType.THEME_NOT_FOUND, HttpStatus.BAD_REQUEST)
every {
paymentClient.cancelPayment(any())
} returns PaymentFixture.createCancelResponse()
val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
Long::class.java
).singleResult
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(invalidRequest)
}.When {
post("/reservations")
}.Then {
log().all()
statusCode(expectedException.httpStatus.value())
body("errorType", equalTo(expectedException.errorType.name))
}
val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
Long::class.java
).singleResult
canceledPaymentSizeAfterApiCall shouldBe canceledPaymentSizeBeforeApiCall + 1L
}
}
context("GET /reservations") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("관리자이면 정상 응답") {
login(MemberFixture.create(role = Role.ADMIN))
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations")
}.Then {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size }))
}
}
}
context("GET /reservations-mine") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("로그인한 회원이 자신의 예약 목록을 조회한다.") {
val member: MemberEntity = login(reservations.keys.first())
val expectedReservations: Int = reservations[member]?.size ?: 0
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations-mine")
}.Then {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(expectedReservations))
}
}
}
context("GET /reservations/search") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("관리자만 검색할 수 있다.") {
login(reservations.keys.first())
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/search")
}.Then {
log().all()
header(HttpHeaders.CONTENT_TYPE, containsString(MediaType.TEXT_HTML_VALUE))
}
}
test("파라미터를 지정하지 않으면 전체 목록 응답") {
login(MemberFixture.create(role = Role.ADMIN))
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/search")
}.Then {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size }))
}
}
test("시작 날짜가 종료 날짜 이전이면 예외 응답") {
login(MemberFixture.create(role = Role.ADMIN))
val startDate = LocalDate.now().plusDays(1)
val endDate = LocalDate.now()
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
param("dateFrom", startDate.toString())
param("dateTo", endDate.toString())
}.When {
get("/reservations/search")
}.Then {
log().all()
statusCode(HttpStatus.BAD_REQUEST.value())
body("errorType", equalTo(ErrorType.INVALID_DATE_RANGE.name))
}
}
test("동일한 회원의 모든 예약 응답") {
login(MemberFixture.create(role = Role.ADMIN))
val member: MemberEntity = reservations.keys.first()
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
param("memberId", member.id)
}.When {
get("/reservations/search")
}.Then {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(reservations[member]?.size ?: 0))
}
}
test("동일한 테마의 모든 예약 응답") {
login(MemberFixture.create(role = Role.ADMIN))
val themes = reservations.values.flatten().map { it.theme }
val requestThemeId: Long = themes.first().id!!
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
param("themeId", requestThemeId)
}.When {
get("/reservations/search")
}.Then {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(themes.filter { it.id == requestThemeId }.size))
}
}
test("시작 날짜와 종료 날짜 사이의 예약 응답") {
login(MemberFixture.create(role = Role.ADMIN))
val dateFrom: LocalDate = reservations.values.flatten().minOf { it.date }
val dateTo: LocalDate = reservations.values.flatten().maxOf { it.date }
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
param("dateFrom", dateFrom.toString())
param("dateTo", dateTo.toString())
}.When {
get("/reservations/search")
}.Then {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size }))
}
}
}
context("DELETE /reservations/{id}") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("관리자만 예약을 삭제할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER))
val reservation: ReservationEntity = reservations.values.flatten().first()
Given {
port(port)
}.When {
delete("/reservations/${reservation.id}")
}.Then {
log().all()
statusCode(302)
header(HttpHeaders.LOCATION, containsString("/login"))
}
}
test("결제되지 않은 예약은 바로 제거") {
login(MemberFixture.create(role = Role.ADMIN))
val reservationId: Long = reservations.values.flatten().first().id!!
transactionTemplate.execute {
val reservation: ReservationEntity = entityManager.find(
ReservationEntity::class.java,
reservationId
)
reservation.reservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
entityManager.persist(reservation)
entityManager.flush()
entityManager.clear()
}
Given {
port(port)
}.When {
delete("/reservations/$reservationId")
}.Then {
log().all()
statusCode(HttpStatus.NO_CONTENT.value())
}
// 예약이 삭제되었는지 확인
transactionTemplate.executeWithoutResult {
val deletedReservation = entityManager.find(
ReservationEntity::class.java,
reservationId
)
deletedReservation shouldBe null
}
}
test("결제된 예약은 취소 후 제거") {
login(MemberFixture.create(role = Role.ADMIN))
val reservation: ReservationEntity = reservations.values.flatten().first()
lateinit var payment: PaymentEntity
transactionTemplate.execute {
payment = PaymentFixture.create(reservation = reservation).also {
entityManager.persist(it)
entityManager.flush()
entityManager.clear()
}
}
every {
paymentClient.cancelPayment(any())
} returns PaymentFixture.createCancelResponse()
val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
Long::class.java
).singleResult
Given {
port(port)
}.When {
delete("/reservations/${reservation.id}")
}.Then {
log().all()
statusCode(HttpStatus.NO_CONTENT.value())
}
val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
Long::class.java
).singleResult
canceledPaymentSizeAfterApiCall shouldBe canceledPaymentSizeBeforeApiCall + 1L
}
}
context("POST /reservations/admin") {
test("관리자가 예약을 추가하면 결제 대기 상태로 예약 생성") {
val member = login(MemberFixture.create(role = Role.ADMIN))
val adminRequest: AdminReservationRequest = createRequest().let {
AdminReservationRequest(
date = it.date,
themeId = it.themeId,
timeId = it.timeId,
memberId = member.id!!,
)
}
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(adminRequest)
}.When {
post("/reservations/admin")
}.Then {
log().all()
statusCode(201)
body("data.status", equalTo(ReservationStatus.CONFIRMED_PAYMENT_REQUIRED.name))
}
}
}
context("GET /reservations/waiting") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("관리자가 아니면 조회할 수 없다.") {
login(MemberFixture.create(role = Role.MEMBER))
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/waiting")
}.Then {
log().all()
header(HttpHeaders.CONTENT_TYPE, containsString(MediaType.TEXT_HTML_VALUE))
}
}
test("대기 중인 예약 목록을 조회한다.") {
login(MemberFixture.create(role = Role.ADMIN))
val expected = reservations.values.flatten()
.count { it.reservationStatus == ReservationStatus.WAITING }
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/waiting")
}.Then {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(expected))
}
}
}
context("POST /reservations/waiting") {
test("회원이 대기 예약을 추가한다.") {
val member = login(MemberFixture.create(role = Role.MEMBER))
val waitingRequest: WaitingRequest = createRequest().let {
WaitingRequest(
date = it.date,
themeId = it.themeId,
timeId = it.timeId
)
}
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(waitingRequest)
}.When {
post("/reservations/waiting")
}.Then {
log().all()
statusCode(201)
body("data.member.id", equalTo(member.id!!.toInt()))
body("data.status", equalTo(ReservationStatus.WAITING.name))
}
}
test("이미 예약된 시간, 테마로 대기 예약 요청 시 예외 응답") {
val member = login(MemberFixture.create(role = Role.MEMBER))
val reservationRequest = createRequest()
transactionTemplate.executeWithoutResult {
val reservation = ReservationFixture.create(
date = reservationRequest.date,
theme = entityManager.find(ThemeEntity::class.java, reservationRequest.themeId),
reservationTime = entityManager.find(ReservationTimeEntity::class.java, reservationRequest.timeId),
member = member,
status = ReservationStatus.WAITING
)
entityManager.persist(reservation)
entityManager.flush()
entityManager.clear()
}
// 이미 예약된 시간, 테마로 대기 예약 요청
val waitingRequest = WaitingRequest(
date = reservationRequest.date,
themeId = reservationRequest.themeId,
timeId = reservationRequest.timeId
)
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(waitingRequest)
}.When {
post("/reservations/waiting")
}.Then {
log().all()
statusCode(HttpStatus.BAD_REQUEST.value())
body("errorType", equalTo(ErrorType.HAS_RESERVATION_OR_WAITING.name))
}
}
}
context("DELETE /reservations/waiting/{id}") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("대기 중인 예약을 취소한다.") {
val member = login(MemberFixture.create(role = Role.MEMBER))
val waiting: ReservationEntity = createSingleReservation(
member = member,
status = ReservationStatus.WAITING
)
Given {
port(port)
}.When {
delete("/reservations/waiting/${waiting.id}")
}.Then {
log().all()
statusCode(HttpStatus.NO_CONTENT.value())
}
transactionTemplate.executeWithoutResult { _ ->
entityManager.find(
ReservationEntity::class.java,
waiting.id
) shouldBe null
}
}
test("이미 완료된 예약은 삭제할 수 없다.") {
val member = login(MemberFixture.create(role = Role.MEMBER))
val reservation: ReservationEntity = createSingleReservation(
member = member,
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
Given {
port(port)
}.When {
delete("/reservations/waiting/{id}", reservation.id)
}.Then {
log().all()
body("errorType", equalTo(ErrorType.RESERVATION_NOT_FOUND.name))
statusCode(HttpStatus.NOT_FOUND.value())
}
}
}
context("POST /reservations/waiting/{id}/approve") {
test("관리자만 승인할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER))
Given {
port(port)
}.When {
post("/reservations/waiting/1/approve")
}.Then {
log().all()
statusCode(302)
header(HttpHeaders.LOCATION, containsString("/login"))
}
}
test("대기 예약을 승인하면 결제 대기 상태로 변경") {
val member = login(MemberFixture.create(role = Role.ADMIN))
val reservation = createSingleReservation(
member = member,
status = ReservationStatus.WAITING
)
Given {
port(port)
}.When {
post("/reservations/waiting/${reservation.id!!}/approve")
}.Then {
log().all()
statusCode(200)
}
transactionTemplate.executeWithoutResult { _ ->
entityManager.find(
ReservationEntity::class.java,
reservation.id
)?.also {
it.reservationStatus shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
} ?: throw AssertionError("Reservation not found")
}
}
}
context("POST /reservations/waiting/{id}/deny") {
test("관리자만 거절할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER))
Given {
port(port)
}.When {
post("/reservations/waiting/1/deny")
}.Then {
log().all()
statusCode(302)
header(HttpHeaders.LOCATION, containsString("/login"))
}
}
test("거절된 예약은 삭제된다.") {
val member = login(MemberFixture.create(role = Role.ADMIN))
val reservation = createSingleReservation(
member = member,
status = ReservationStatus.WAITING
)
Given {
port(port)
}.When {
post("/reservations/waiting/${reservation.id!!}/deny")
}.Then {
log().all()
statusCode(204)
}
transactionTemplate.executeWithoutResult { _ ->
entityManager.find(
ReservationEntity::class.java,
reservation.id
) shouldBe null
}
}
}
}
fun createSingleReservation(
date: LocalDate = LocalDate.now().plusDays(1),
time: LocalTime = LocalTime.now(),
themeName: String = "Default Theme",
member: MemberEntity = MemberFixture.create(role = Role.MEMBER),
status: ReservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
): ReservationEntity {
return ReservationFixture.create(
date = date,
theme = ThemeFixture.create(name = themeName),
reservationTime = ReservationTimeFixture.create(startAt = time),
member = member,
status = status
).also { it ->
transactionTemplate.execute { _ ->
if (member.id == null) {
entityManager.persist(member)
}
entityManager.persist(it.reservationTime)
entityManager.persist(it.theme)
entityManager.persist(it)
entityManager.flush()
entityManager.clear()
}
}
}
fun createDummyReservations(): MutableMap<MemberEntity, MutableList<ReservationEntity>> {
val reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>> = mutableMapOf()
val members: List<MemberEntity> = listOf(
MemberFixture.create(role = Role.MEMBER),
MemberFixture.create(role = Role.MEMBER)
)
transactionTemplate.executeWithoutResult {
members.forEach { member ->
entityManager.persist(member)
}
entityManager.flush()
entityManager.clear()
}
transactionTemplate.executeWithoutResult {
repeat(10) { index ->
val theme = ThemeFixture.create(name = "theme$index")
val time = ReservationTimeFixture.create(startAt = LocalTime.now().plusMinutes(index.toLong()))
entityManager.persist(theme)
entityManager.persist(time)
val reservation = ReservationFixture.create(
date = LocalDate.now().plusDays(index.toLong()),
theme = theme,
reservationTime = time,
member = members[index % members.size],
status = ReservationStatus.CONFIRMED
)
entityManager.persist(reservation)
reservations.getOrPut(reservation.member) { mutableListOf() }.add(reservation)
}
entityManager.flush()
entityManager.clear()
}
return reservations
}
fun createRequest(
theme: ThemeEntity = ThemeFixture.create(),
time: ReservationTimeEntity = ReservationTimeFixture.create(),
): ReservationRequest {
lateinit var reservationRequest: ReservationRequest
transactionTemplate.executeWithoutResult {
entityManager.persist(theme)
entityManager.persist(time)
reservationRequest = ReservationFixture.createRequest(
themeId = theme.id!!,
timeId = time.id!!,
)
entityManager.flush()
entityManager.clear()
}
return reservationRequest
}
fun login(member: MemberEntity): MemberEntity {
if (member.id == null) {
transactionTemplate.executeWithoutResult {
entityManager.persist(member)
entityManager.flush()
entityManager.clear()
}
}
if (member.isAdmin()) {
loginAsAdmin()
} else {
loginAsUser()
}
resolveMemberId(member.id!!)
return member
}
private fun loginAsUser() {
every {
loginInterceptor.preHandle(any(), any(), any())
} returns true
}
private fun loginAsAdmin() {
every {
adminInterceptor.preHandle(any(), any(), any())
} returns true
}
private fun resolveMemberId(memberId: Long) {
every {
memberIdResolver.resolveArgument(any(), any(), any(), any())
} returns memberId
}
}

View File

@ -0,0 +1,306 @@
package roomescape.reservation.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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import roomescape.common.config.JacksonConfig
import roomescape.common.exception.ErrorType
import roomescape.reservation.business.ReservationTimeService
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationTimeEntity
import roomescape.reservation.infrastructure.persistence.ReservationTimeRepository
import roomescape.util.ReservationFixture
import roomescape.util.ReservationTimeFixture
import roomescape.util.RoomescapeApiTest
import roomescape.util.ThemeFixture
import java.time.LocalDate
import java.time.LocalTime
@WebMvcTest(ReservationTimeController::class)
@Import(JacksonConfig::class)
class ReservationTimeControllerTest(
val mockMvc: MockMvc,
) : RoomescapeApiTest() {
@SpykBean
private lateinit var reservationTimeService: ReservationTimeService
@MockkBean
private lateinit var reservationTimeRepository: ReservationTimeRepository
@MockkBean
private lateinit var reservationRepository: ReservationRepository
init {
Given("등록된 모든 시간을 조회할 때") {
val endpoint = "/times"
When("관리자인 경우") {
beforeTest {
loginAsAdmin()
}
Then("정상 응답") {
every {
reservationTimeRepository.findAll()
} returns listOf(
ReservationTimeFixture.create(id = 1L),
ReservationTimeFixture.create(id = 2L)
)
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { isOk() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.data.times[0].id") { value(1) }
jsonPath("$.data.times[1].id") { value(2) }
}
}
}
}
When("관리자가 아닌 경우") {
loginAsUser()
Then("로그인 페이지로 이동") {
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { is3xxRedirection() }
header { string("Location", "/login") }
}
}
}
}
Given("시간을 추가할 때") {
val endpoint = "/times"
When("관리자인 경우") {
beforeTest {
loginAsAdmin()
}
val time = LocalTime.of(10, 0)
val request = ReservationTimeRequest(startAt = time)
Then("시간 형식이 HH:mm이 아니거나, 범위를 벗어나면 400 응답") {
listOf(
"{\"startAt\": \"23:30:30\"}",
"{\"startAt\": \"24:59\"}",
).forEach {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = it,
log = true
) {
status { isBadRequest() }
}
}
}
Then("정상 응답") {
every {
reservationTimeService.addTime(request)
} returns ReservationTimeResponse(id = 1, startAt = time)
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { isCreated() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.data.id") { value(1) }
jsonPath("$.data.startAt") { value("10:00") }
}
}
}
Then("동일한 시간이 존재하면 409 응답") {
every {
reservationTimeRepository.existsByStartAt(time)
} returns true
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { isConflict() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.errorType") { value(ErrorType.TIME_DUPLICATED.name) }
}
}
}
}
When("관리자가 아닌 경우") {
loginAsUser()
Then("로그인 페이지로 이동") {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = ReservationTimeFixture.create(),
log = true
) {
status { is3xxRedirection() }
header { string("Location", "/login") }
}
}
}
}
Given("시간을 삭제할 때") {
val endpoint = "/times/1"
When("관리자인 경우") {
beforeTest {
loginAsAdmin()
}
Then("정상 응답") {
every {
reservationTimeService.removeTimeById(1L)
} returns Unit
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { isNoContent() }
}
}
Then("없는 시간을 조회하면 400 응답") {
val id = 1L
every {
reservationTimeRepository.findByIdOrNull(id)
} returns null
runDeleteTest(
mockMvc = mockMvc,
endpoint = "/times/$id",
log = true
) {
status { isBadRequest() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.errorType") { value(ErrorType.RESERVATION_TIME_NOT_FOUND.name) }
}
}
}
Then("예약이 있는 시간을 삭제하면 409 응답") {
val id = 1L
every {
reservationTimeRepository.findByIdOrNull(id)
} returns ReservationTimeFixture.create(id = id)
every {
reservationRepository.findByReservationTime(any())
} returns listOf(ReservationFixture.create())
runDeleteTest(
mockMvc = mockMvc,
endpoint = "/times/$id",
log = true
) {
status { isConflict() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.errorType") { value(ErrorType.TIME_IS_USED_CONFLICT.name) }
}
}
}
}
When("관리자가 아닌 경우") {
loginAsUser()
Then("로그인 페이지로 이동") {
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { is3xxRedirection() }
header { string("Location", "/login") }
}
}
}
}
Given("날짜, 테마가 주어졌을 때") {
loginAsUser()
val date: LocalDate = LocalDate.now()
val themeId = 1L
When("저장된 예약 시간이 있으면") {
val times: List<ReservationTimeEntity> = listOf(
ReservationTimeFixture.create(id = 1L, startAt = LocalTime.of(10, 0)),
ReservationTimeFixture.create(id = 2L, startAt = LocalTime.of(11, 0))
)
every {
reservationTimeRepository.findAll()
} returns times
Then("그 시간과, 해당 날짜와 테마에 대한 예약 여부가 담긴 목록을 응답") {
every {
reservationRepository.findByDateAndThemeId(date, themeId)
} returns listOf(
ReservationFixture.create(
id = 1L,
date = date,
theme = ThemeFixture.create(id = themeId),
reservationTime = times[0]
)
)
val response = runGetTest(
mockMvc = mockMvc,
endpoint = "/times/filter?date=$date&themeId=$themeId",
log = true
) {
status { isOk() }
content {
contentType(MediaType.APPLICATION_JSON)
}
}.andReturn().readValue(ReservationTimeInfosResponse::class.java)
assertSoftly(response.times) {
this shouldHaveSize times.size
this[0].id shouldBe times[0].id
this[0].alreadyBooked shouldBe true
this[1].id shouldBe times[1].id
this[1].alreadyBooked shouldBe false
}
}
}
}
}
}

View File

@ -1,9 +1,9 @@
package roomescape.theme.util
import jakarta.persistence.EntityManager
import roomescape.member.infrastructure.persistence.Member
import roomescape.reservation.domain.ReservationStatus
import roomescape.reservation.domain.ReservationTime
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.infrastructure.persistence.ReservationTimeEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
@ -20,16 +20,16 @@ object TestThemeCreateUtil {
date: LocalDate,
): ThemeEntity {
val themeEntity: ThemeEntity = ThemeFixture.create(name = name).also { entityManager.persist(it) }
val member: Member = MemberFixture.create().also { entityManager.persist(it) }
val member: MemberEntity = MemberFixture.create().also { entityManager.persist(it) }
for (i in 1..reservedCount) {
val time: ReservationTime = ReservationTimeFixture.create(
val time: ReservationTimeEntity = ReservationTimeFixture.create(
startAt = LocalTime.now().plusMinutes(i.toLong())
).also { entityManager.persist(it) }
ReservationFixture.create(
date = date,
themeEntity = themeEntity,
theme = themeEntity,
member = member,
reservationTime = time,
status = ReservationStatus.CONFIRMED

View File

@ -8,10 +8,11 @@ import jakarta.persistence.EntityManager
import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.context.jdbc.Sql
import org.springframework.transaction.support.TransactionTemplate
import roomescape.theme.business.ThemeService
import roomescape.theme.util.TestThemeCreateUtil
import roomescape.util.CleanerMode
import roomescape.util.DatabaseCleanerExtension
import java.time.LocalDate
import kotlin.random.Random
@ -21,13 +22,14 @@ import kotlin.random.Random
* 날짜 범위, 예약 수만 검증
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql(value = ["/truncate.sql"], executionPhase = Sql.ExecutionPhase.AFTER_TEST_CLASS)
class MostReservedThemeAPITest(
@LocalServerPort val port: Int,
val themeService: ThemeService,
val transactionTemplate: TransactionTemplate,
val entityManager: EntityManager,
) : FunSpec() {
) : FunSpec({
extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_SPEC))
}) {
init {
beforeSpec {
transactionTemplate.executeWithoutResult {

View File

@ -201,12 +201,13 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
)
every {
themeRepository.existsByName(request.name)
} returns false
every {
themeRepository.save(any())
} returns theme
themeService.save(request)
} returns ThemeResponse(
id = theme.id!!,
name = theme.name,
description = theme.description,
thumbnail = theme.thumbnail
)
Then("201 응답을 받는다.") {
runPostTest(

View File

@ -0,0 +1,64 @@
package roomescape.util
import io.kotest.core.listeners.AfterSpecListener
import io.kotest.core.listeners.AfterTestListener
import io.kotest.core.spec.Spec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.extensions.spring.testContextManager
import jakarta.persistence.EntityManager
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
@Component
class DatabaseCleaner(
val entityManager: EntityManager,
val jdbcTemplate: JdbcTemplate,
) {
val tables: List<String> by lazy {
jdbcTemplate.query("SHOW TABLES") { rs, _ ->
rs.getString(1).lowercase()
}
}
fun clear() {
entityManager.clear()
jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE")
tables.forEach {
jdbcTemplate.execute("TRUNCATE TABLE $it RESTART IDENTITY")
}
jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE")
}
}
enum class CleanerMode {
AFTER_EACH_TEST,
AFTER_SPEC
}
class DatabaseCleanerExtension(
private val mode: CleanerMode
) : AfterTestListener, AfterSpecListener {
override suspend fun afterTest(testCase: TestCase, result: TestResult) {
super.afterTest(testCase, result)
when (mode) {
CleanerMode.AFTER_EACH_TEST -> getCleaner().clear()
CleanerMode.AFTER_SPEC -> Unit
}
}
override suspend fun afterSpec(spec: Spec) {
super.afterSpec(spec)
when (mode) {
CleanerMode.AFTER_EACH_TEST -> Unit
CleanerMode.AFTER_SPEC -> getCleaner().clear()
}
}
private suspend fun getCleaner(): DatabaseCleaner {
return testContextManager().testContext
.applicationContext
.getBean(DatabaseCleaner::class.java)
}
}

View File

@ -2,15 +2,17 @@ package roomescape.util
import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.web.LoginRequest
import roomescape.member.infrastructure.persistence.Member
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.web.PaymentApprove
import roomescape.payment.web.PaymentCancel
import roomescape.reservation.domain.Reservation
import roomescape.reservation.domain.ReservationStatus
import roomescape.reservation.domain.ReservationTime
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.infrastructure.persistence.ReservationTimeEntity
import roomescape.reservation.web.ReservationRequest
import roomescape.reservation.web.WaitingRequest
import roomescape.theme.infrastructure.persistence.ThemeEntity
import java.time.LocalDate
import java.time.LocalTime
@ -26,23 +28,25 @@ object MemberFixture {
account: String = "default",
password: String = "password",
role: Role = Role.ADMIN
): Member = Member(id, name, "$account@email.com", password, role)
): MemberEntity = MemberEntity(id, name, "$account@email.com", password, role)
fun admin(): Member = create(
fun admin(): MemberEntity = create(
id = 2L,
account = "admin",
role = Role.ADMIN
)
fun adminLoginRequest(): LoginRequest = LoginRequest(
email = admin().email,
password = admin().password
)
fun user(): Member = create(
fun user(): MemberEntity = create(
id = 1L,
account = "user",
role = Role.MEMBER
)
fun userLoginRequest(): LoginRequest = LoginRequest(
email = user().email,
password = user().password
@ -53,7 +57,7 @@ object ReservationTimeFixture {
fun create(
id: Long? = null,
startAt: LocalTime = LocalTime.now().plusHours(1),
): ReservationTime = ReservationTime(id, startAt)
): ReservationTimeEntity = ReservationTimeEntity(id, startAt)
}
object ThemeFixture {
@ -69,11 +73,39 @@ object ReservationFixture {
fun create(
id: Long? = null,
date: LocalDate = LocalDate.now().plusWeeks(1),
themeEntity: ThemeEntity = ThemeFixture.create(),
reservationTime: ReservationTime = ReservationTimeFixture.create(),
member: Member = MemberFixture.create(),
theme: ThemeEntity = ThemeFixture.create(),
reservationTime: ReservationTimeEntity = ReservationTimeFixture.create(),
member: MemberEntity = MemberFixture.create(),
status: ReservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
): Reservation = Reservation(id, date, reservationTime, themeEntity, member, status)
): ReservationEntity = ReservationEntity(id, date, reservationTime, theme, member, status)
fun createRequest(
date: LocalDate = LocalDate.now().plusWeeks(1),
themeId: Long = 1L,
timeId: Long = 1L,
paymentKey: String = "paymentKey",
orderId: String = "orderId",
amount: Long = 10000L,
paymentType: String = "NORMAL",
): ReservationRequest = ReservationRequest(
date = date,
timeId = timeId,
themeId = themeId,
paymentKey = paymentKey,
orderId = orderId,
amount = amount,
paymentType = paymentType
)
fun createWaitingRequest(
date: LocalDate = LocalDate.now().plusWeeks(1),
themeId: Long = 1L,
timeId: Long = 1L
): WaitingRequest = WaitingRequest(
date = date,
timeId = timeId,
themeId = themeId
)
}
object JwtFixture {
@ -96,14 +128,14 @@ object PaymentFixture {
orderId: String = ORDER_ID,
paymentKey: String = PAYMENT_KEY,
totalAmount: Long = AMOUNT,
reservationId: Long = Random.nextLong(),
reservation: ReservationEntity = ReservationFixture.create(id = 1L),
approvedAt: OffsetDateTime = OffsetDateTime.now()
): PaymentEntity = PaymentEntity(
id = id,
orderId = orderId,
paymentKey = paymentKey,
totalAmount = totalAmount,
reservation = ReservationFixture.create(id = reservationId),
reservation = reservation,
approvedAt = approvedAt
)

View File

@ -1,7 +1,6 @@
package roomescape.util
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
import io.kotest.core.spec.style.BehaviorSpec
@ -15,10 +14,11 @@ import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.web.support.AdminInterceptor
import roomescape.auth.web.support.LoginInterceptor
import roomescape.auth.web.support.MemberIdResolver
import roomescape.common.config.JacksonConfig
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Member
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID
@ -42,9 +42,9 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
@MockkBean
lateinit var jwtHandler: JwtHandler
val objectMapper: ObjectMapper = jacksonObjectMapper()
val admin: Member = MemberFixture.admin()
val user: Member = MemberFixture.user()
val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
val admin: MemberEntity = MemberFixture.admin()
val user: MemberEntity = MemberFixture.user()
fun runGetTest(
mockMvc: MockMvc,
@ -71,9 +71,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
}
}.apply {
log.takeIf { it }?.let { this.andDo { print() } }
}.andExpect {
assert
}
}.andExpect(assert)
fun runDeleteTest(
mockMvc: MockMvc,
@ -84,9 +82,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
header(HttpHeaders.COOKIE, "accessToken=token")
}.apply {
log.takeIf { it }?.let { this.andDo { print() } }
}.andExpect {
assert
}
}.andExpect(assert)
fun loginAsAdmin() {
every {

View File

@ -1,194 +0,0 @@
INSERT INTO member (name, password, email, role)
VALUES ('이름', '12341234', 'test@test.com', 'MEMBER');
INSERT INTO member (name, password, email, role)
VALUES ('관리자', '12341234', 'admin@admin.com', 'ADMIN');
-- 테마 목록 : 11개
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마1', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마2', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마3', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마4', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마5', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마6', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마7', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마8', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마9', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마10', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
INSERT INTO theme (name, description, thumbnail)
VALUES ('테마11', '재밌는 테마입니다',
'https://www.google.co.kr/url?sa=i&url=http%3A%2F%2Fwww.code-k.co.kr%2Fsub%2Fcode_sub03.html%3FR_JIJEM%3DS1&psig=AOvVaw20fNjL28MSMMiR0Nb57Eh-&ust=1714695060162000&source=images&cd=vfe&opi=89978449&ved=0CBIQjRxqFwoTCOiO2oLX7YUDFQAAAAAdAAAAABAE');
-- 예약 시간 목록 : 5개
INSERT INTO reservation_time (start_at)
VALUES ('08:00');
INSERT INTO reservation_time (start_at)
VALUES ('10:00');
INSERT INTO reservation_time (start_at)
VALUES ('13:00');
INSERT INTO reservation_time (start_at)
VALUES ('21:00');
INSERT INTO reservation_time (start_at)
VALUES ('23:00');
-- 5,4,2,5,2,3,1,1,1,1,1
-- 내림차순 정렬 ID : 4/1, 2, 6, 3/5, 7/8/9/10/11
-- 테마 1 예약 목록 : 5개
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 1, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 1, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 3, 1, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 4, 1, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 5, 1, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-1', 'paymentKey-1', 10000, 1, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-2', 'paymentKey-2', 20000, 2, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-3', 'paymentKey-3', 30000, 3, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-4', 'paymentKey-4', 40000, 4, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-5', 'paymentKey-5', 50000, 5, CURRENT_DATE);
-- 테마 2 예약 목록 : 4개
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 2, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 2, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 3, 2, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 4, 2, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-6', 'paymentKey-6', 50000, 6, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-7', 'paymentKey-7', 50000, 7, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-8', 'paymentKey-8', 50000, 8, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-9', 'paymentKey-9', 50000, 9, CURRENT_DATE);
-- 테마 3 예약 목록 : 2개
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 3, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 3, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-10', 'paymentKey-10', 50000, 10, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-11', 'paymentKey-11', 50000, 11, CURRENT_DATE);
-- 테마 4 예약 목록 : 5개
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 4, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 4, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 3, 4, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 4, 4, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 5, 4, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-12', 'paymentKey-12', 50000, 12, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-13', 'paymentKey-13', 50000, 13, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-14', 'paymentKey-14', 50000, 14, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-15', 'paymentKey-15', 50000, 15, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-16', 'paymentKey-16', 50000, 16, CURRENT_DATE);
-- 테마 5 예약 목록 : 2개
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 5, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 5, 5, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-17', 'paymentKey-17', 50000, 17, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-18', 'paymentKey-18', 50000, 18, CURRENT_DATE);
-- 테마 6 예약 목록 : 3개
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 6, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 2, 6, 1, 'CONFIRMED');
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 3, 6, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-19', 'paymentKey-19', 50000, 19, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-20', 'paymentKey-20', 50000, 20, CURRENT_DATE);
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-21', 'paymentKey-21', 50000, 21, CURRENT_DATE);
-- 테마 7 예약 목록
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 7, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-22', 'paymentKey-22', 50000, 22, CURRENT_DATE);
-- 테마 8 예약 목록
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 8, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-23', 'paymentKey-23', 50000, 23, CURRENT_DATE);
-- 테마 9 예약 목록
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 9, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-24', 'paymentKey-24', 50000, 24, CURRENT_DATE);
-- 테마 10 예약 목록
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 1, 10, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-25', 'paymentKey-25', 50000, 25, CURRENT_DATE);
-- 테마 11 예약 목록
INSERT INTO reservation (date, time_id, theme_id, member_id, reservation_status)
VALUES (DATEADD('DAY', -3, CURRENT_DATE), 5, 11, 1, 'CONFIRMED');
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-26', 'paymentKey-26', 50000, 26, CURRENT_DATE);

View File

@ -1,41 +0,0 @@
-- 관리자가 특정 조건에 해당되는 예약을 조회하는 테스트에서만 사용되는 데이터입니다.
insert into reservation_time(start_at)
values ('15:00');
insert into theme(name, description, thumbnail)
values ('테스트1', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
insert into theme(name, description, thumbnail)
values ('테스트2', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
insert into member(name, email, password, role)
values ('어드민', 'a@a.a', 'a', 'ADMIN');
insert into member(name, email, password, role)
values ('1호', '1@1.1', '1', 'MEMBER');
-- 예약
-- 시간은 같은 시간으로, 날짜는 어제부터 7일 전까지
-- memberId = 1인 멤버는 3개의 예약, memberId = 2인 멤버는 4개의 예약이 있음
-- themeId = 1인 테마는 4개의 예약, themeId = 2인 테마는 3개의 예약이 있음
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', -1, CURRENT_DATE()), 1, 1, 1, 'CONFIRMED');
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', -2, CURRENT_DATE()), 1, 1, 1, 'CONFIRMED');
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', -3, CURRENT_DATE()), 1, 1, 1, 'CONFIRMED');
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', -4, CURRENT_DATE()), 1, 1, 2, 'CONFIRMED');
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', -5, CURRENT_DATE()), 1, 2, 2, 'CONFIRMED');
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', -6, CURRENT_DATE()), 1, 2, 2, 'CONFIRMED');
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', -7, CURRENT_DATE()), 1, 2, 2, 'CONFIRMED');
-- 예약 대기
-- 예약 대기는 조회되면 안됨.
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', 7, CURRENT_DATE()), 1, 1, 1, 'WAITING');
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', 8, CURRENT_DATE()), 1, 1, 1, 'WAITING');
insert into reservation(date, time_id, theme_id, member_id, reservation_status)
values (DATEADD('DAY', 9, CURRENT_DATE()), 1, 1, 2, 'WAITING');

View File

@ -1,25 +0,0 @@
DELETE
FROM payment;
DELETE
FROM canceled_payment;
DELETE
FROM reservation;
DELETE
FROM reservation_time;
DELETE
FROM theme;
DELETE
FROM member;
ALTER TABLE payment
ALTER COLUMN id RESTART WITH 1;
ALTER TABLE canceled_payment
ALTER COLUMN id RESTART WITH 1;
ALTER TABLE reservation
ALTER COLUMN id RESTART WITH 1;
ALTER TABLE reservation_time
ALTER COLUMN id RESTART WITH 1;
ALTER TABLE theme
ALTER COLUMN id RESTART WITH 1;
ALTER TABLE member
ALTER COLUMN id RESTART WITH 1;