Compare commits

...

6 Commits

5 changed files with 426 additions and 9 deletions

View File

@@ -101,15 +101,8 @@ tasks.withType<KotlinCompile> {
tasks.withType<Test> {
useJUnitPlatform()
val springContextCacheMaxSize = (project.findProperty("test.springContextCacheMaxSize") as String?) ?: "1"
maxHeapSize = (project.findProperty("testMaxHeap") as String?) ?: "1536m"
maxParallelForks = 1
jvmArgs(
"-Dfile.encoding=UTF-8",
"-Dspring.test.context.cache.maxSize=$springContextCacheMaxSize"
)
maxHeapSize = "4096m"
jvmArgs("-Dfile.encoding=UTF-8")
}
tasks.getByName<Jar>("jar") {

View File

@@ -0,0 +1,154 @@
# 라이브 예약 LazyInitializationException 수정 Plan/TASK
> **For agentic workers:** REQUIRED SUB-SKILL: `superpowers:executing-plans`를 사용해 각 task를 순서대로 실행한다. 각 단계는 체크박스 상태와 검증 기록을 즉시 갱신한다.
**Goal:** OSIV off 환경에서 라이브 예약 생성이 `LiveRoom.reservations` lazy 컬렉션 초기화 예외 없이 완료되고, 결제와 예약 저장이 하나의 트랜잭션에 참여하게 한다.
**Architecture:** 기존 API와 엔티티 매핑은 유지한다. `LiveReservationService.makeReservation()`을 서비스 계층의 쓰기 트랜잭션 경계로 만들고, 트랜잭션이 없는 통합 테스트에서 실제 Spring 프록시를 호출해 detached `LiveRoom`의 lazy 컬렉션 접근 오류를 재현하고 수정한다.
**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5, H2, Gradle Wrapper
## Global Constraints
- 공개 API URL, 요청 및 응답 스키마를 변경하지 않는다.
- `spring.jpa.open-in-view=false`를 유지한다.
- `LiveRoom.reservations`의 fetch 전략과 `LiveReservation.room` setter를 변경하지 않는다.
- 예약 중복 방지, 결제 정책, 응답 포맷을 변경하지 않는다.
- 변경은 PRD, Plan/TASK, `LiveReservationService.makeReservation()`, 해당 통합 테스트로 제한한다.
---
## 파일 구조 계획
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt`
- 실제 Spring 서비스 프록시와 JPA 엔티티로 OSIV off 예약 생성 경로를 검증한다.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt`
- `makeReservation()`에 쓰기 `@Transactional`을 추가한다.
- Modify: `docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md`
- RED/GREEN/회귀 검증 결과를 누적 기록한다.
---
### Phase 1: LazyInitializationException 재현
- [x] **Task 1.1: 라이브 예약 서비스 통합 실패 테스트 작성**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt`
- RED: `@SpringBootTest``@Transactional(propagation = Propagation.NOT_SUPPORTED)`를 사용해 테스트 자체 트랜잭션이 서비스 경계를 가리지 않게 한다.
- RED: `EmbeddedRedisInitializer`를 명시적으로 적용하고 클래스 종료 후 Context를 정리한다.
- RED: `TransactionTemplate` 안에서 예약자, 크리에이터, 가격이 0인 예약 라이브방을 저장하고 `EntityManager.flush()`, `EntityManager.clear()`를 실행한다.
- RED: `MockHttpServletRequest``RequestContextHolder`에 등록해 request-scoped `LangContext`를 사용할 수 있게 한 뒤 실제 Spring 빈 `service.makeReservation(...)`을 호출한다.
- RED 코드의 핵심 검증은 다음과 같다.
```kotlin
val response = service.makeReservation(
request = MakeLiveReservationRequest(
roomId = fixture.roomId,
container = "web",
timezone = "Asia/Seoul"
),
memberId = fixture.memberId
)
val reservation = transactionTemplate.execute {
repository.findById(response.reservationId).orElseThrow()
}!!
assertEquals(fixture.roomId, reservation.room!!.id)
assertEquals(fixture.memberId, reservation.member!!.id)
```
- 실패 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
- 기대 결과: production code 수정 전 `reservation.room = room`에서 `LiveRoom.reservations`를 초기화하려다 `LazyInitializationException`으로 실패한다.
- GREEN: 이 task에서는 production code를 변경하지 않는다.
- REFACTOR: fixture와 결과 검증용 타입은 테스트 파일 내부 private data class로 제한하고, request context는 `@AfterEach`에서 해제한다.
- 검증 기록:
- 무엇: 트랜잭션 없는 테스트 메서드에서 detached `LiveRoom`을 다시 조회하는 실제 Spring `LiveReservationService` 빈을 호출했다.
- 왜: 테스트 트랜잭션이나 OSIV가 결함을 가리지 않은 상태에서 운영 오류와 같은 lazy 컬렉션 접근을 재현하기 위해서다.
- 어떻게: production code 수정 전 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행했다.
- 결과: `LiveReservationServiceIntegrationTest.kt:55`에서 `failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.live.room.LiveRoom.reservations, could not initialize proxy - no Session`으로 실패해 RED를 확인했다. Stack trace는 `PersistentBag.add`를 가리켰다.
---
### Phase 2: 서비스 쓰기 트랜잭션 적용
- [x] **Task 2.1: `makeReservation()` 트랜잭션 경계 추가**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt`
- Consumes: `LiveReservationService.makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse`
- Produces: 같은 메서드 시그니처와 응답을 유지하는 transactional 예약 생성 흐름
- GREEN: 기존 import인 `org.springframework.transaction.annotation.Transactional`을 사용해 다음 한 줄만 추가한다.
```kotlin
@Transactional
fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse {
```
- 통과 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
- 기대 결과: `BUILD SUCCESSFUL`이며 저장된 예약의 방 ID와 회원 ID가 fixture와 일치한다.
- REFACTOR: 불필요한 fetch 전략, setter, 응답 로직 변경이 없는지 `git diff`로 확인한다.
- 검증 기록:
- 무엇: `LiveReservationService.makeReservation()`에 쓰기 `@Transactional`을 추가했다.
- 왜: 라이브방 조회부터 lazy 컬렉션 접근, 결제, 예약 저장까지 같은 영속성 컨텍스트와 트랜잭션에서 처리하기 위해서다.
- 어떻게: RED와 같은 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 재실행했다.
- 결과: `BUILD SUCCESSFUL in 51s`로 통과했고 저장된 예약의 방 ID와 회원 ID가 fixture와 일치했다.
---
### Phase 3: 회귀 및 문서 검증
- [x] **Task 3.1: 관련 테스트와 저장소 규칙 검증**
- Verify: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
- Verify: `./gradlew --no-daemon ktlintCheck`
- Verify: `./gradlew --no-daemon tasks --all`
- Verify: `git diff --check`
- 기대 결과: 모든 Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없이 exit code 0이다.
- RED/GREEN: Phase 1과 Phase 2의 실패 및 통과 결과를 다시 확인한다.
- REFACTOR: 이번 요청과 무관한 코드 및 문서 변경이 없는지 확인한다.
- 검증 기록:
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`: GREEN 확인 실행은 `BUILD SUCCESSFUL in 51s`, 최종 재실행은 `BUILD SUCCESSFUL in 11s`로 통과했다.
- `./gradlew --no-daemon ktlintCheck`: `BUILD SUCCESSFUL in 23s`로 통과했다.
- `./gradlew --no-daemon tasks --all`: `BUILD SUCCESSFUL in 6s`로 통과했다.
- `./gradlew --no-daemon test`: 전체 테스트 스위트가 `BUILD SUCCESSFUL in 5m 48s`로 통과했다.
- `git diff --check`: 출력 없이 통과했다.
- `git diff`: production code 변경이 `makeReservation()``@Transactional` 한 줄뿐이며 fetch 전략, setter, API 응답 로직은 변경하지 않았음을 확인했다.
---
### Phase 4: 유료 예약 원자성 회귀 보강
- [x] **Task 4.1: 예약 저장 실패 시 결제와 예약의 전체 롤백 검증**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt`
- Modify: `docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md`
- RED: 실제 `CanPaymentService`와 JPA 저장소를 사용하는 유료 예약 fixture를 만들고, `LiveReservationRepository.save()` spy에서 `UseCan` 증가를 확인한 뒤 `DataIntegrityViolationException`을 던지게 한다.
- RED: 기존 production code가 이미 `@Transactional`을 포함하므로, 임시로 `noRollbackFor = [DataIntegrityViolationException::class]` 변이를 적용했을 때 결제 잔액 검증이 실패하는지 확인한 뒤 즉시 원복한다.
- GREEN: 원래 `@Transactional`에서 같은 테스트가 통과하고 회원 캔 잔액, 충전 잔액, 사용 내역 건수와 예약 존재 여부가 호출 전 상태와 같은지 검증한다.
- REFACTOR: 새 production code나 별도 추상화를 추가하지 않고 기존 통합 테스트 fixture만 최소 확장한다.
- Verify: `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
- 기대 결과: 2개 테스트가 실행되고 모두 통과하며, 테스트 결과 XML의 failures/errors가 0이다.
- 범위 분리: 전체 테스트/clean build의 KAPT 산출물 재현성 문제는 이번 기능 변경에 포함하지 않고 별도 빌드 작업으로 분리한다.
- 검증 기록:
- 무엇: 유료 예약의 저장 실패 뒤 회원 캔, 충전 잔액, `UseCan` 건수와 예약 존재 여부를 별도 트랜잭션에서 다시 조회했다.
- 왜: `CanPaymentService.spendCan()`의 기본 `REQUIRED` 전파가 외부 예약 트랜잭션에 참여해 결제 변경도 함께 롤백되는지 직접 확인하기 위해서다.
- 결제 선행 확인: 예약 저장 실패를 발생시키기 직전에 같은 트랜잭션의 `UseCan` 건수가 호출 전보다 1 증가했는지 확인해 결제가 저장보다 먼저 실행됐음을 고정했다.
- RED: `makeReservation()`에 임시 `noRollbackFor = [DataIntegrityViolationException::class]` 변이를 적용하고 새 단일 테스트를 실행했다. XML은 tests=1, failures=1이며 회원 캔 검증이 expected 100, actual 0으로 실패했다.
- GREEN: 변이를 즉시 원복하고 같은 단일 테스트를 재실행했다. `BUILD SUCCESSFUL in 58s`, XML은 tests=1, failures=0, errors=0이다.
- 리뷰 보정: 예약 저장 실패 직전 `UseCan` 증가 assertion을 추가한 뒤 정상 경계에서 단일 테스트가 `BUILD SUCCESSFUL in 39s`로 통과했다. 같은 변이를 다시 적용하면 XML tests=1, failures=1과 expected 100, actual 0을 재현했다.
- 최종 GREEN: 변이를 원복하고 `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행했다. `BUILD SUCCESSFUL in 3m 47s`, XML은 tests=2, failures=0, errors=0이다.
- production code 원복 확인: `git diff -- src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt`가 출력 없이 종료됐다.
---
## 검증 기록
- 계획 작성 시점에는 production code와 테스트를 변경하지 않았다.
- 2026-07-15: 문서 변경 후 `./gradlew --no-daemon tasks --all` 명령 유효성을 확인했다.
- sandbox 실행은 Gradle wrapper lock 파일 접근 제한으로 실패했다.
- 승인 실행은 `BUILD SUCCESSFUL in 11s`로 통과했다.
- 2026-07-15: `git diff --check`가 출력 없이 통과해 문서 공백 오류가 없음을 확인했다.
- 2026-07-15: production code 수정 전 단일 통합 테스트가 예상한 `LiveRoom.reservations``LazyInitializationException`으로 실패해 RED를 확인했다.
- 2026-07-15: `makeReservation()``@Transactional`을 추가한 뒤 같은 통합 테스트가 통과해 GREEN을 확인했다.
- 2026-07-15: 관련 단일 테스트, `ktlintCheck`, `tasks --all`, `git diff --check`가 최종 통과했다.
- 2026-07-15: 완료 선언 전 `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행해 캐시 없이 `BUILD SUCCESSFUL in 3m 39s`를 확인했다. 출력된 deprecation/unchecked cast 경고는 기존 파일에서 발생했으며 이번 변경 파일과 무관하다.
- 2026-07-15: 브랜치 완료 전 전체 회귀 검증으로 `./gradlew --no-daemon test`를 실행해 `BUILD SUCCESSFUL in 5m 48s`를 확인했다.
- 2026-07-15: 유료 예약 원자성 회귀 테스트 보강 후 같은 통합 테스트 클래스를 `--rerun-tasks`로 실행해 `BUILD SUCCESSFUL in 3m 47s`, XML tests=2, failures=0, errors=0을 확인했다.
- 2026-07-15: `./gradlew --no-daemon ktlintCheck``BUILD SUCCESSFUL in 22s`, `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL in 6s`로 통과했다.
- 2026-07-15: 최초 캐시 사용 단일 테스트 명령은 `:test NO-SOURCE`와 빈 테스트 산출물로 종료되어 검증 증거로 인정하지 않았다. `--rerun-tasks` 실행에서는 실제 테스트 XML을 확인했으며, 이 산출물 재현성 현상의 원인 조사와 수정은 별도 빌드 작업으로 분리한다.

View File

@@ -0,0 +1,63 @@
# PRD: 라이브 예약 LazyInitializationException 수정
## 1. Overview
`spring.jpa.open-in-view=false` 환경에서 라이브 예약 생성 시 `LiveRoom.reservations` lazy 컬렉션 접근으로 발생하는 `LazyInitializationException`을 서비스 트랜잭션 경계로 방지한다.
## 2. Problem
- `LiveReservationService.makeReservation()`에는 쓰기 트랜잭션 경계가 없다.
- `liveRoomRepository.findByIdOrNull()` 호출이 끝난 뒤 반환된 `LiveRoom`은 영속성 컨텍스트에서 분리된다.
- `reservation.room = room``LiveReservation.room`의 사용자 정의 setter를 호출하고, setter는 `room.reservations.add(this)`로 lazy 컬렉션을 초기화한다.
- OSIV가 비활성화된 상태에서는 컬렉션을 초기화할 Session이 없어 `org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.live.room.LiveRoom.reservations, could not initialize proxy - no Session`이 발생한다.
- 유료 예약에서는 `CanPaymentService.spendCan()`만 자체 트랜잭션으로 먼저 커밋될 수 있어, 이후 예약 저장이 실패하면 결제와 예약 상태가 분리될 위험도 있다.
## 3. Goals
- OSIV off 환경에서도 라이브 예약 생성이 lazy 초기화 예외 없이 완료된다.
- `makeReservation()`의 라이브방 조회, 결제, 예약 저장을 하나의 트랜잭션 경계에서 처리한다.
- 트랜잭션 없는 테스트 메서드에서 실제 Spring 서비스 프록시를 호출해 기존 오류를 재현하고 회귀를 방지한다.
- 기존 라이브 예약 API URL, 요청 및 응답 스키마를 변경하지 않는다.
## 4. Non-Goals
- `spring.jpa.open-in-view`를 활성화하지 않는다.
- `LiveRoom.reservations`를 eager fetch로 변경하지 않는다.
- `LiveReservation.room`의 양방향 연관관계 setter를 재설계하지 않는다.
- 예약 중복 방지나 동시성 정책을 새로 도입하지 않는다.
- 결제 및 예약 정책, 응답 문구와 날짜 포맷을 변경하지 않는다.
## 5. Target Users
- 사용자: 무료 또는 유료 라이브를 오류 없이 예약하려는 회원
- 운영자: OSIV off 정책을 유지하면서 결제와 예약 저장의 일관성을 보장하려는 운영 담당자
## 6. User Stories
- 사용자는 예약 가능한 라이브를 선택했을 때 서버의 lazy 초기화 오류 없이 예약을 완료할 수 있어야 한다.
- 유료 라이브 예약은 결제와 예약 저장 중 하나가 실패하면 전체 작업이 함께 롤백되어야 한다.
- 운영자는 OSIV와 엔티티 fetch 전략을 변경하지 않고 서비스 트랜잭션 경계로 오류를 방지할 수 있어야 한다.
## 7. Core Features
### Feature A. 라이브 예약 생성 트랜잭션 보강
#### Requirements
- `LiveReservationService.makeReservation()`에 쓰기 `@Transactional`을 적용한다.
- `liveRoomRepository.findByIdOrNull()`로 조회한 `LiveRoom`은 예약 연관관계 설정과 저장이 끝날 때까지 managed 상태를 유지한다.
- `CanPaymentService.spendCan()`의 기본 `REQUIRED` 전파 속성은 외부 예약 트랜잭션에 참여한다.
- 기존 비밀번호 검증, 중복 예약 검증, 보유 캔 검증, 결제, 예약 응답 생성 순서를 유지한다.
#### Edge Cases
- 가격이 0인 무료 라이브도 `LiveRoom.reservations` lazy 컬렉션 초기화 예외 없이 예약된다.
- 가격이 0보다 큰 라이브는 결제와 예약 저장이 같은 트랜잭션에 참여한다.
- 존재하지 않는 라이브방이나 회원, 잘못된 비밀번호, 중복 예약, 캔 부족에 대한 기존 예외 동작을 유지한다.
## 8. Technical Constraints
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, JUnit 5, Gradle Wrapper를 사용한다.
- 서비스 쓰기 메서드 단위로 `@Transactional` 경계를 명확히 한다.
- 테스트 클래스 자체 트랜잭션은 비활성화해 서비스 프록시의 트랜잭션 경계만 검증한다.
- 실제 JPA 엔티티를 저장하고 영속성 컨텍스트를 비운 뒤 서비스를 호출하는 통합 테스트로 검증한다.
- 변경 범위는 `LiveReservationService.makeReservation()`, 해당 회귀 테스트, PRD와 Plan/TASK 문서로 제한한다.
## 9. Metrics
- 수정 전 회귀 테스트가 `LazyInitializationException`으로 실패한다.
- 수정 후 같은 회귀 테스트가 통과하고 예약 레코드가 저장된다.
- 관련 테스트, `ktlintCheck`, `tasks --all`, `git diff --check`가 통과한다.
## 10. Open Questions
- 없음. 승인된 권장안인 서비스 쓰기 트랜잭션 적용과 OSIV off 통합 회귀 테스트로 범위를 확정한다.

View File

@@ -29,6 +29,7 @@ class LiveReservationService(
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@Transactional
fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse {
val room = liveRoomRepository.findByIdOrNull(id = request.roomId)
?: throw SodaException(messageKey = "live.reservation.invalid_request_retry")

View File

@@ -0,0 +1,206 @@
package kr.co.vividnext.sodalive.live.reservation
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.SpyBean
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:live-reservation-service-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class LiveReservationServiceIntegrationTest @Autowired constructor(
private val service: LiveReservationService,
private val repository: LiveReservationRepository,
private val memberRepository: MemberRepository,
private val chargeRepository: ChargeRepository,
private val useCanRepository: UseCanRepository,
private val transactionTemplate: TransactionTemplate,
private val entityManager: EntityManager
) {
@SpyBean
private lateinit var repositorySpy: LiveReservationRepository
@BeforeEach
fun setUpRequestContext() {
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
}
@AfterEach
fun resetRequestContext() {
RequestContextHolder.resetRequestAttributes()
}
@Test
@DisplayName("라이브 예약은 OSIV off 환경에서도 lazy reservations 접근까지 완료된다")
fun shouldMakeReservationThroughTransactionalServiceProxyWhenOpenInViewIsDisabled() {
val fixture = createFixture()
val response = service.makeReservation(
request = MakeLiveReservationRequest(
roomId = fixture.roomId,
container = "web",
timezone = "Asia/Seoul"
),
memberId = fixture.memberId
)
val savedReservation = transactionTemplate.execute {
val reservation = repository.findById(response.reservationId).orElseThrow()
SavedReservation(
roomId = reservation.room!!.id!!,
memberId = reservation.member!!.id!!
)
}!!
assertEquals(fixture.roomId, savedReservation.roomId)
assertEquals(fixture.memberId, savedReservation.memberId)
}
@Test
@DisplayName("유료 예약 저장이 실패하면 결제와 예약을 함께 롤백한다")
fun shouldRollbackPaymentAndReservationWhenPaidReservationSaveFails() {
val fixture = createFixture(price = 100)
val useCanCountBefore = useCanRepository.count()
Mockito.doAnswer {
assertEquals(useCanCountBefore + 1, useCanRepository.count())
throw DataIntegrityViolationException("reservation save failed")
}
.`when`(repositorySpy)
.save(Mockito.any(LiveReservation::class.java))
val exception = assertThrows(DataIntegrityViolationException::class.java) {
service.makeReservation(
request = MakeLiveReservationRequest(
roomId = fixture.roomId,
container = "web",
timezone = "Asia/Seoul"
),
memberId = fixture.memberId
)
}
val rollbackState = transactionTemplate.execute {
val member = memberRepository.findById(fixture.memberId).orElseThrow()
val charge = chargeRepository.findById(fixture.chargeId!!).orElseThrow()
RollbackState(
memberCan = member.getChargeCan("web") + member.getRewardCan("web"),
chargeCan = charge.chargeCan + charge.rewardCan,
useCanCount = useCanRepository.count(),
reservationExists = repository.isExistsReservation(fixture.roomId, fixture.memberId)
)
}!!
assertEquals("reservation save failed", exception.message)
assertEquals(fixture.price, rollbackState.memberCan)
assertEquals(fixture.price, rollbackState.chargeCan)
assertEquals(useCanCountBefore, rollbackState.useCanCount)
assertEquals(false, rollbackState.reservationExists)
}
private fun createFixture(price: Int = 0): Fixture {
return transactionTemplate.execute {
val creator = Member(
email = "live-reservation-creator@test.com",
password = "password",
nickname = "live-reservation-creator"
)
entityManager.persist(creator)
val member = Member(
email = "live-reservation-member@test.com",
password = "password",
nickname = "live-reservation-member"
)
member.pgChargeCan = price
entityManager.persist(member)
val charge = if (price > 0) {
Charge(chargeCan = price, rewardCan = 0).also {
it.member = member
it.payment = Payment(
status = PaymentStatus.COMPLETE,
paymentGateway = PaymentGateway.PG
)
entityManager.persist(it)
}
} else {
null
}
val room = LiveRoom(
title = "예약 라이브",
notice = "예약 라이브 안내",
beginDateTime = LocalDateTime.now().plusDays(1),
numberOfPeople = 10,
isAdult = false,
price = price
)
room.member = creator
entityManager.persist(room)
entityManager.flush()
val fixture = Fixture(
roomId = room.id!!,
memberId = member.id!!,
chargeId = charge?.id,
price = price
)
entityManager.clear()
fixture
}!!
}
private data class Fixture(
val roomId: Long,
val memberId: Long,
val chargeId: Long?,
val price: Int
)
private data class SavedReservation(
val roomId: Long,
val memberId: Long
)
private data class RollbackState(
val memberCan: Int,
val chargeCan: Int,
val useCanCount: Long,
val reservationExists: Boolean
)
}