diff --git a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java b/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java index a92d5512..865841c1 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java @@ -1,20 +1,24 @@ package org.sejongisc.backend.betting.controller; +import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.betting.dto.UserBetRequest; import org.sejongisc.backend.betting.entity.BetRound; import org.sejongisc.backend.betting.entity.Scope; +import org.sejongisc.backend.betting.entity.UserBet; import org.sejongisc.backend.betting.service.BettingService; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.user.entity.User; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Optional; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -24,7 +28,7 @@ public class BettingController { private final BettingService bettingService; - @GetMapping("/bet-round/{scope}") + @GetMapping("/bet-rounds/{scope}") public ResponseEntity getTodayBetRound( @PathVariable @Pattern(regexp = "daily|weekly") String scope){ @@ -37,10 +41,30 @@ public ResponseEntity getTodayBetRound( .orElseGet(() -> ResponseEntity.notFound().build()); } - @GetMapping("/bet-round/history") + @GetMapping("/bet-rounds/history") public ResponseEntity> getAllBetRounds(){ List betRounds = bettingService.getAllBetRounds(); return ResponseEntity.ok(betRounds); } + + @PostMapping("/user-bets") + public ResponseEntity postUserBet( + @AuthenticationPrincipal CustomUserDetails principal, + @RequestBody @Valid UserBetRequest userBetRequest){ + + UserBet userBet = bettingService.postUserBet(principal.getUserId(), userBetRequest); + + return ResponseEntity.ok(userBet); + } + + @DeleteMapping("/user-bets/{userBetId}") + public ResponseEntity cancelUserBet( + @AuthenticationPrincipal CustomUserDetails principal, + @PathVariable UUID userBetId){ + + bettingService.cancelUserBet(principal.getUserId(), userBetId); + return ResponseEntity.noContent().build(); + } + } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java b/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java new file mode 100644 index 00000000..c91770ac --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java @@ -0,0 +1,29 @@ +package org.sejongisc.backend.betting.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sejongisc.backend.betting.entity.BetOption; + +import java.util.UUID; + +@Getter +@Builder @AllArgsConstructor @NoArgsConstructor +public class UserBetRequest { + + @NotNull(message = "라운드 ID는 필수입니다.") + private UUID roundId; + + @NotNull(message = "베팅 옵션은 필수입니다.") + private BetOption option; + + @JsonProperty("isFree") + private boolean free; + + @Min(value = 10, message = "베팅 포인트는 10 이상이어야 합니다.") + private Integer stakePoints; +} diff --git a/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java b/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java index 334f4c72..a0bd3ca8 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java @@ -1,11 +1,17 @@ package org.sejongisc.backend.betting.entity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; import java.util.UUID; @Entity +@Getter +@Builder @NoArgsConstructor @AllArgsConstructor public class UserBet extends BasePostgresEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) diff --git a/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java b/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java new file mode 100644 index 00000000..991e8e96 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java @@ -0,0 +1,13 @@ +package org.sejongisc.backend.betting.repository; + +import org.sejongisc.backend.betting.entity.BetRound; +import org.sejongisc.backend.betting.entity.UserBet; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface UserBetRepository extends JpaRepository { + boolean existsByRoundAndUserId(BetRound round, UUID userId); + Optional findByUserBetIdAndUserId(UUID userBetId, UUID userId); +} diff --git a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java index 031edb9a..82a33565 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java @@ -1,19 +1,24 @@ package org.sejongisc.backend.betting.service; import lombok.RequiredArgsConstructor; -import org.sejongisc.backend.betting.entity.BetRound; -import org.sejongisc.backend.betting.entity.Scope; -import org.sejongisc.backend.betting.entity.Stock; +import org.sejongisc.backend.betting.dto.UserBetRequest; +import org.sejongisc.backend.betting.entity.*; import org.sejongisc.backend.betting.repository.BetRoundRepository; import org.sejongisc.backend.betting.repository.StockRepository; +import org.sejongisc.backend.betting.repository.UserBetRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.point.entity.PointOrigin; +import org.sejongisc.backend.point.entity.PointReason; +import org.sejongisc.backend.point.service.PointHistoryService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.Random; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -21,6 +26,8 @@ public class BettingService { private final BetRoundRepository betRoundRepository; private final StockRepository stockRepository; + private final UserBetRepository userBetRepository; + private final PointHistoryService pointHistoryService; private final Random random = new Random(); @@ -70,4 +77,67 @@ public void closeBetRound(){ // TODO : status를 false로 바꿔야함, 정산 로직 구현하면서 같이 할 것 } + @Transactional + public UserBet postUserBet(UUID userId, UserBetRequest userBetRequest) { + BetRound betRound = betRoundRepository.findById(userBetRequest.getRoundId()) + .orElseThrow(() -> new CustomException(ErrorCode.BET_ROUND_NOT_FOUND)); + + if (userBetRepository.existsByRoundAndUserId(betRound, userId)) { + throw new CustomException(ErrorCode.BET_DUPLICATE); + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(betRound.getOpenAt()) || now.isAfter(betRound.getLockAt())) { + throw new CustomException(ErrorCode.BET_TIME_INVALID); + } + + int stake = 0; + + if (!userBetRequest.isFree()) { + pointHistoryService.createPointHistory( + userId, + -userBetRequest.getStakePoints(), + PointReason.BETTING, + PointOrigin.BETTING, + userBetRequest.getRoundId() + ); + stake = userBetRequest.getStakePoints(); + } + + UserBet userBet = UserBet.builder() + .round(betRound) + .userId(userId) + .option(userBetRequest.getOption()) + .isFree(userBetRequest.isFree()) + .stakePoints(stake) + .betStatus(BetStatus.ACTIVE) + .build(); + + return userBetRepository.save(userBet); + } + + @Transactional + public void cancelUserBet(UUID userId, UUID userBetId) { + UserBet userBet = userBetRepository.findByUserBetIdAndUserId(userBetId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.BET_NOT_FOUND)); + + BetRound betRound = userBet.getRound(); + + if (LocalDateTime.now().isAfter(betRound.getLockAt())){ + throw new CustomException(ErrorCode.BET_ROUND_CLOSED); + } + + if (!userBet.isFree() && userBet.getStakePoints() > 0) { + pointHistoryService.createPointHistory( + userId, + userBet.getStakePoints(), + PointReason.BETTING, + PointOrigin.BETTING, + userBet.getUserBetId() + ); + } + + userBetRepository.delete(userBet); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 91ff86ff..3dfb57e0 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -53,7 +53,12 @@ public enum ErrorCode { // BETTING - STOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "주식 종목이 존재하지 않습니다."); + STOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "주식 종목이 존재하지 않습니다."), + BET_ROUND_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 라운드입니다."), + BET_DUPLICATE(HttpStatus.CONFLICT, "이미 이 라운드에 베팅했습니다."), + BET_TIME_INVALID(HttpStatus.CONFLICT, "베팅 가능 시간이 아닙니다."), + BET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 베팅을 찾을 수 없습니다."), + BET_ROUND_CLOSED(HttpStatus.CONFLICT, "이미 마감된 라운드입니다."); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java b/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java index 77874f7a..e2c472a5 100644 --- a/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java +++ b/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java @@ -19,6 +19,7 @@ import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.UUID; @@ -56,7 +57,7 @@ public PointHistoryResponse getPointHistoryListByUserId(UUID userId, PageRequest } // 포인트 증감 기록 생성 및 유저 포인트 업데이트 - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) @Retryable( // 어떤 예외가 발생했을 때 재시도할지 지정합니다. include = {OptimisticLockingFailureException.class}, diff --git a/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTest.java b/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTest.java index d1df5e80..76b84ba0 100644 --- a/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTest.java @@ -4,14 +4,17 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.sejongisc.backend.betting.entity.BetRound; -import org.sejongisc.backend.betting.entity.Scope; -import org.sejongisc.backend.betting.entity.Stock; -import org.sejongisc.backend.betting.entity.MarketType; +import org.sejongisc.backend.betting.dto.UserBetRequest; +import org.sejongisc.backend.betting.entity.*; import org.sejongisc.backend.betting.repository.BetRoundRepository; import org.sejongisc.backend.betting.repository.StockRepository; +import org.sejongisc.backend.betting.repository.UserBetRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.point.entity.PointOrigin; +import org.sejongisc.backend.point.entity.PointReason; +import org.sejongisc.backend.point.service.PointHistoryService; +import org.sejongisc.backend.user.dao.UserRepository; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -28,13 +31,31 @@ class BettingServiceTest { private BetRoundRepository betRoundRepository; private StockRepository stockRepository; + private UserBetRepository userBetRepository; + private PointHistoryService pointHistoryService; + private BettingService bettingService; + private UUID userId; + private UUID roundId; + @BeforeEach void setUp() { betRoundRepository = mock(BetRoundRepository.class); stockRepository = mock(StockRepository.class); - bettingService = new BettingService(betRoundRepository, stockRepository); + userBetRepository = mock(UserBetRepository.class); + pointHistoryService = mock(PointHistoryService.class); + + // BettingService 생성자 시그니처에 맞춰 주입 + bettingService = new BettingService( + betRoundRepository, + stockRepository, + userBetRepository, + pointHistoryService + ); + + userId = UUID.randomUUID(); + roundId = UUID.randomUUID(); } @Test @@ -212,4 +233,203 @@ void findActiveRound_Weekly_Success() { assertThat(result.get().getScope()).isEqualTo(Scope.WEEKLY); verify(betRoundRepository, times(1)).findByStatusTrueAndScope(Scope.WEEKLY); } + + private BetRound openRoundNow() { + LocalDateTime now = LocalDateTime.now(); + return BetRound.builder() + .betRoundID(roundId) + .scope(Scope.DAILY) + .status(true) + .title("OPEN") + .openAt(now.minusMinutes(1)) + .lockAt(now.plusMinutes(10)) + .build(); + } + + private BetRound closedRoundNow() { + LocalDateTime now = LocalDateTime.now(); + return BetRound.builder() + .betRoundID(roundId) + .scope(Scope.DAILY) + .status(true) + .title("CLOSED") + .openAt(now.minusMinutes(10)) + .lockAt(now.minusMinutes(1)) + .build(); + } + + private UserBetRequest paidReq(int stake) { + return UserBetRequest.builder() + .roundId(roundId) + .option(BetOption.RISE) + .free(false) + .stakePoints(stake) + .build(); + } + + private UserBetRequest freeReq() { + return UserBetRequest.builder() + .roundId(roundId) + .option(BetOption.FALL) + .free(true) + .stakePoints(999) // 무시되어야 함 + .build(); + } + + @Test + @DisplayName("postUserBet: 유료 베팅 성공 → 포인트 차감 호출 + 저장") + void postUserBet_paid_success() { + BetRound round = openRoundNow(); + + when(betRoundRepository.findById(roundId)).thenReturn(Optional.of(round)); + when(userBetRepository.existsByRoundAndUserId(round, userId)).thenReturn(false); + when(userBetRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UserBetRequest req = paidReq(100); + + UserBet result = bettingService.postUserBet(userId, req); + + assertThat(result.getStakePoints()).isEqualTo(100); + assertThat(result.isFree()).isFalse(); + + verify(pointHistoryService).createPointHistory( + eq(userId), eq(-100), + eq(PointReason.BETTING), + eq(PointOrigin.BETTING), + eq(roundId) + ); + verify(userBetRepository).save(any(UserBet.class)); + } + + @Test + @DisplayName("postUserBet: 무료 베팅 성공 → 포인트 차감 호출 안함, stake=0") + void postUserBet_free_success() { + BetRound round = openRoundNow(); + + when(betRoundRepository.findById(roundId)).thenReturn(Optional.of(round)); + when(userBetRepository.existsByRoundAndUserId(round, userId)).thenReturn(false); + when(userBetRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UserBetRequest req = freeReq(); + + UserBet result = bettingService.postUserBet(userId, req); + + assertThat(result.isFree()).isTrue(); + assertThat(result.getStakePoints()).isZero(); + + verify(pointHistoryService, never()).createPointHistory(any(), anyInt(), any(), any(), any()); + verify(userBetRepository).save(any(UserBet.class)); + } + + @Test + @DisplayName("postUserBet: 라운드 없음 → BET_ROUND_NOT_FOUND") + void postUserBet_round_not_found() { + when(betRoundRepository.findById(roundId)).thenReturn(Optional.empty()); + + CustomException ex = assertThrows(CustomException.class, + () -> bettingService.postUserBet(userId, paidReq(100))); + + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.BET_ROUND_NOT_FOUND); + verifyNoInteractions(pointHistoryService); + } + + @Test + @DisplayName("postUserBet: 중복 베팅 → BET_DUPLICATE") + void postUserBet_duplicate() { + BetRound round = openRoundNow(); + when(betRoundRepository.findById(roundId)).thenReturn(Optional.of(round)); + when(userBetRepository.existsByRoundAndUserId(round, userId)).thenReturn(true); + + CustomException ex = assertThrows(CustomException.class, + () -> bettingService.postUserBet(userId, paidReq(100))); + + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.BET_DUPLICATE); + verifyNoInteractions(pointHistoryService); + verify(userBetRepository, never()).save(any()); + } + + @Test + @DisplayName("postUserBet: 베팅 시간 아님 → BET_TIME_INVALID") + void postUserBet_time_invalid() { + BetRound closed = closedRoundNow(); + when(betRoundRepository.findById(roundId)).thenReturn(Optional.of(closed)); + when(userBetRepository.existsByRoundAndUserId(closed, userId)).thenReturn(false); + + CustomException ex = assertThrows(CustomException.class, + () -> bettingService.postUserBet(userId, paidReq(100))); + + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.BET_TIME_INVALID); + verifyNoInteractions(pointHistoryService); + verify(userBetRepository, never()).save(any()); + } + + @Test + @DisplayName("cancelUserBet: 유료 베팅 취소 → 환불 호출 + 삭제") + void cancelUserBet_paid_refund_and_delete() { + BetRound round = openRoundNow(); + UUID userBetId = UUID.randomUUID(); + + UserBet bet = UserBet.builder() + .userBetId(userBetId) + .round(round) + .userId(userId) + .isFree(false) + .stakePoints(200) + .betStatus(BetStatus.ACTIVE) + .build(); + + when(userBetRepository.findByUserBetIdAndUserId(userBetId, userId)) + .thenReturn(Optional.of(bet)); + + bettingService.cancelUserBet(userId, userBetId); + + verify(pointHistoryService).createPointHistory( + eq(userId), eq(200), + eq(PointReason.BETTING), + eq(PointOrigin.BETTING), + eq(userBetId) + ); + verify(userBetRepository).delete(bet); + } + + @Test + @DisplayName("cancelUserBet: 본인 소유/존재 X → BET_NOT_FOUND") + void cancelUserBet_not_found() { + UUID userBetId = UUID.randomUUID(); + when(userBetRepository.findByUserBetIdAndUserId(userBetId, userId)) + .thenReturn(Optional.empty()); + + CustomException ex = assertThrows(CustomException.class, + () -> bettingService.cancelUserBet(userId, userBetId)); + + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.BET_NOT_FOUND); + verifyNoInteractions(pointHistoryService); + verify(userBetRepository, never()).delete(any()); + } + + @Test + @DisplayName("cancelUserBet: 마감 이후 취소 → BET_ROUND_CLOSED") + void cancelUserBet_after_lock() { + BetRound closed = closedRoundNow(); + UUID userBetId = UUID.randomUUID(); + + UserBet bet = UserBet.builder() + .userBetId(userBetId) + .round(closed) + .userId(userId) + .isFree(false) + .stakePoints(200) + .betStatus(BetStatus.ACTIVE) + .build(); + + when(userBetRepository.findByUserBetIdAndUserId(userBetId, userId)) + .thenReturn(Optional.of(bet)); + + CustomException ex = assertThrows(CustomException.class, + () -> bettingService.cancelUserBet(userId, userBetId)); + + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.BET_ROUND_CLOSED); + verify(pointHistoryService, never()).createPointHistory(any(), anyInt(), any(), any(), any()); + verify(userBetRepository, never()).delete(any()); + } } \ No newline at end of file diff --git a/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTransactionalTest.java b/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTransactionalTest.java new file mode 100644 index 00000000..2bb139ee --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTransactionalTest.java @@ -0,0 +1,80 @@ +package org.sejongisc.backend.betting.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.sejongisc.backend.betting.dto.UserBetRequest; +import org.sejongisc.backend.betting.entity.BetOption; +import org.sejongisc.backend.betting.entity.BetRound; +import org.sejongisc.backend.betting.repository.BetRoundRepository; +import org.sejongisc.backend.betting.repository.StockRepository; // ← 의존하면 반드시 목! +import org.sejongisc.backend.betting.repository.UserBetRepository; +import org.sejongisc.backend.point.entity.PointHistory; +import org.sejongisc.backend.point.repository.PointHistoryRepository; +import org.sejongisc.backend.point.service.PointHistoryService; +import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig(classes = BettingServiceTransactionalTest.TestConfig.class) +class BettingServiceTransactionalTest { + + @Configuration + @EnableRetry + @Import({BettingService.class, PointHistoryService.class}) + static class TestConfig {} + + @Autowired + BettingService bettingService; + + @MockBean UserRepository userRepository; + @MockBean BetRoundRepository betRoundRepository; + @MockBean UserBetRepository userBetRepository; + @MockBean PointHistoryRepository pointHistoryRepository; + @MockBean StockRepository stockRepository; + + @Test + void 포인트히스토리가_UserBet_저장_전에_호출되고_외부트랜잭션_롤백시에도_정상동작한다() { + UUID userId = UUID.randomUUID(); + User user = User.builder().point(1000).build(); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + BetRound round = BetRound.builder() + .openAt(LocalDateTime.now().minusMinutes(5)) + .lockAt(LocalDateTime.now().plusMinutes(10)) + .build(); + given(betRoundRepository.findById(any())).willReturn(Optional.of(round)); + + UserBetRequest req = UserBetRequest.builder() + .roundId(UUID.randomUUID()) + .option(BetOption.RISE) + .stakePoints(100) + .free(false) + .build(); + + given(pointHistoryRepository.save(any(PointHistory.class))) + .willAnswer(inv -> inv.getArgument(0)); + + doThrow(new RuntimeException("강제로 외부 트랜잭션 롤백 발생")) + .when(userBetRepository).save(any()); + + assertThatThrownBy(() -> bettingService.postUserBet(userId, req)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("외부 트랜잭션 롤백"); + + verify(pointHistoryRepository, times(1)).save(any(PointHistory.class)); + verify(userBetRepository, times(1)).save(any()); + } +}