Conversation
코드 리뷰 분석Walkthrough스톡 엔티티를 프라이스데이터로 대체하며 베팅 시스템을 리팩토링합니다. 관리자 컨트롤러 추가, OpenAPI 어노테이션 확대, 사용자 베팅 히스토리 및 정산 기능 강화, Scope 열거형 JSON 직렬화 지원을 포함합니다. Changes
Sequence Diagram(s)sequenceDiagram
participant Admin as 관리자
participant Controller as BettingAdminController
participant Service as BettingService
participant PriceRepo as PriceDataRepository
participant BetRepo as BetRoundRepository
Admin->>Controller: POST /api/admin/bet-rounds/daily/open
Controller->>Service: createBetRound(Scope.DAILY)
Service->>PriceRepo: 모든 PriceData 조회 후 랜덤 선택
PriceRepo-->>Service: PriceData 반환
Service->>Service: PriceData → PriceResponse 변환
Service->>BetRepo: 새 BetRound 생성 및 저장
BetRepo-->>Service: BetRound 저장 완료
Service-->>Controller: 성공 메시지
Controller-->>Admin: 200 OK (ResponseEntity<String>)
sequenceDiagram
participant User as 사용자
participant Controller as BettingController
participant Service as BettingService
participant PriceRepo as PriceDataRepository
participant BetRepo as BetRoundRepository
rect rgb(200, 220, 255)
note over User,BetRepo: 사용자 베팅 흐름 (변경됨: Stock → PriceData)
User->>Controller: POST /user-bets (UserBetRequest)
Controller->>Service: postUserBet(principal, request)
Service->>BetRepo: 베팅 라운드 조회
alt 라운드 없음
Service-->>Controller: BET_ROUND_NOT_FOUND
else 라운드 존재
Service->>PriceRepo: 라운드용 PriceData 확인
Service->>BetRepo: UserBet 저장
Service-->>Controller: UserBet 반환
end
Controller-->>User: 200 OK / 404 / 409
end
User->>Controller: GET /user-bets/history
Controller->>Service: getAllUserBets(principal)
Service->>BetRepo: 사용자 베팅 조회
Service-->>Controller: UserBet 리스트
Controller-->>User: 200 OK (베팅 히스토리)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 주의 필요 사항:
Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java (1)
3-77: JPA 엔티티에 직접@Schema어노테이션을 추가하면 영속성 계층과 API 계층이 결합됩니다.JPA 엔티티에 OpenAPI 어노테이션을 직접 추가하면 다음과 같은 문제가 발생합니다:
- 데이터베이스 스키마 변경이 API 스펙에 직접 영향을 미칩니다
- API 버저닝이 어려워집니다
- 엔티티의 모든 필드가 API에 노출될 위험이 있습니다
관련 파일들을 확인한 결과, 컨트롤러에서 엔티티를 직접 반환하고 있는 것으로 보입니다. DTO(Data Transfer Object) 레이어를 도입하여 API 응답 전용 클래스를 만들고, 그곳에
@Schema어노테이션을 추가하는 것을 권장합니다.예시:
// BetRoundResponse.java (새 파일) @Schema(description = "베팅 라운드 응답") public class BetRoundResponse { @Schema(description = "베팅 라운드의 고유 식별자") private UUID betRoundID; @Schema(description = "라운드 단위") private Scope scope; // ... 필요한 필드만 포함 public static BetRoundResponse from(BetRound betRound) { // 엔티티 -> DTO 변환 로직 } }
🧹 Nitpick comments (1)
backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java (1)
49-50: TODO 주석: status 필드를 Enum으로 변경하는 것을 고려해보세요.현재
status필드는 boolean 타입으로 단순히 열림/닫힘만 표현하고 있습니다. 향후 베팅 라운드가 더 많은 상태(예: 대기중, 진행중, 베팅마감, 정산중, 정산완료 등)를 가질 가능성이 있다면, 지금 Enum으로 변경하는 것이 나중에 리팩토링 비용을 줄일 수 있습니다.Enum 기반 구현을 도와드릴까요?
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (11)
backend/src/main/java/org/sejongisc/backend/betting/controller/BettingAdminController.java(1 hunks)backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java(2 hunks)backend/src/main/java/org/sejongisc/backend/betting/dto/PriceResponse.java(1 hunks)backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java(2 hunks)backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java(3 hunks)backend/src/main/java/org/sejongisc/backend/betting/entity/Scope.java(2 hunks)backend/src/main/java/org/sejongisc/backend/betting/entity/Stock.java(0 hunks)backend/src/main/java/org/sejongisc/backend/betting/repository/StockRepository.java(0 hunks)backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java(6 hunks)backend/src/main/java/org/sejongisc/backend/stock/repository/PriceDataRepository.java(1 hunks)backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTest.java(1 hunks)
💤 Files with no reviewable changes (2)
- backend/src/main/java/org/sejongisc/backend/betting/entity/Stock.java
- backend/src/main/java/org/sejongisc/backend/betting/repository/StockRepository.java
🧰 Additional context used
🧬 Code graph analysis (2)
backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java (1)
backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java (1)
Service(33-104)
backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java (1)
backend/src/main/java/org/sejongisc/backend/auth/controller/EmailController.java (1)
Tag(15-74)
🔇 Additional comments (5)
backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java (1)
79-127: 메서드 문서화가 잘 되어 있습니다.각 메서드에 한글 주석이 추가되어 코드 가독성이 향상되었습니다.
backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java (4)
3-6: OpenAPI 문서화가 체계적으로 추가되었습니다.
@Tag,@Operation,@Parameter,@ApiResponse어노테이션을 통해 API 문서가 명확하게 작성되었습니다. 개발자와 사용자 모두에게 유용한 문서를 제공합니다.Also applies to: 29-29
50-60: 타입 안정성 개선: String 대신 Scope enum 사용.
@PathVariable의 타입을String에서Scopeenum으로 변경하여 컴파일 타임에 타입 안정성이 보장됩니다. Spring이 자동으로 문자열을 enum으로 변환하고, 잘못된 값이 입력되면 400 에러를 반환합니다.
34-48: 상세한 API 문서가 작성되었습니다.각 엔드포인트마다 한글로 명확한 설명과 함께 가능한 응답 코드가 문서화되어 있습니다. 특히 내부 로직과 제약사항까지 설명되어 있어 API 사용자에게 매우 유용합니다.
Also applies to: 63-72, 79-92, 103-115, 127-136
95-96:@Parameter(hidden = true)사용이 적절합니다.
@AuthenticationPrincipal로 주입되는 인증 정보는 Swagger UI에 표시할 필요가 없으므로,@Parameter(hidden = true)를 추가하여 API 문서를 깔끔하게 유지한 것이 좋습니다.Also applies to: 118-119, 139-140
| @GetMapping("/bet-rounds/{scope}") | ||
| public ResponseEntity<BetRound> getTodayBetRound( | ||
| @PathVariable @Pattern(regexp = "daily|weekly") String scope){ | ||
|
|
||
| Scope scopeEnum = Scope.valueOf(scope.toUpperCase()); | ||
|
|
||
| Optional<BetRound> betRound = bettingService.getActiveRound(scopeEnum); | ||
| @Parameter(description = "라운드 범위 (Scope): DAILY 또는 WEEKLY", example = "DAILY") | ||
| @PathVariable Scope scope | ||
| ) { | ||
| Optional<BetRound> betRound = bettingService.getActiveRound(scope); | ||
|
|
||
| return betRound | ||
| .map(ResponseEntity::ok) | ||
| .orElseGet(() -> ResponseEntity.notFound().build()); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
컨트롤러에서 엔티티 객체를 직접 반환하고 있습니다.
현재 모든 엔드포인트에서 BetRound와 UserBet 엔티티를 직접 반환하고 있습니다. 이는 다음과 같은 문제를 야기합니다:
- 영속성 계층과 API 계층의 결합: 엔티티 구조 변경 시 API 스펙이 자동으로 변경됩니다
- 보안 위험: 의도하지 않은 필드(예: 내부 상태, 민감 정보)가 노출될 수 있습니다
- 순환 참조 위험: JPA 관계(@onetomany, @manytoone)가 있을 경우 JSON 직렬화 시 무한 루프 발생 가능
- API 버전 관리 어려움: 엔티티 변경 없이 API만 변경하기 어렵습니다
DTO 패턴을 도입하여 각 엔드포인트에 맞는 응답 클래스를 만드는 것을 강력히 권장합니다.
다음 스크립트로 엔티티에 JPA 관계 어노테이션이 있는지 확인하여 순환 참조 위험을 검증합니다:
Also applies to: 74-77, 94-101, 117-125, 138-144
🏁 Script executed:
#!/bin/bash
# Description: Check for JPA relationship annotations in entities that could cause circular reference issues
# Search for JPA relationship annotations in entity classes
rg -n --type java '@(OneToMany|ManyToOne|OneToOne|ManyToMany)' backend/src/main/java/org/sejongisc/backend/betting/entity/Length of output: 131
추가 검증이 필요합니다. BetRound와 다른 엔티티의 양방향 관계를 확인하겠습니다.
---
🏁 Script executed:
```shell
#!/bin/bash
# Check for @OneToMany in BetRound entity and see relationship configuration
fd -e java --path backend/src/main/java/org/sejongisc/backend/betting/entity -exec grep -l "BetRound\|UserBet" {} \;
Length of output: 233
🏁 Script executed:
#!/bin/bash
# Check for all @OneToMany and bidirectional relationships
rg -n '@OneToMany' backend/src/main/java/org/sejongisc/backend/betting/entity/Length of output: 0
🏁 Script executed:
#!/bin/bash
# Check if serialization control annotations exist
rg -n '@(JsonIgnore|JsonBackReference|JsonManagedReference)' backend/src/main/java/org/sejongisc/backend/betting/entity/Length of output: 0
🏁 Script executed:
#!/bin/bash
# Show UserBet.java full context around the @ManyToOne annotation
cat backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java | head -40Length of output: 1118
---
🏁 Script executed:
```shell
#!/bin/bash
# Find and show BetRound entity
find backend/src/main/java/org/sejongisc/backend/betting/entity -name "BetRound.java" -exec cat {} \;
Length of output: 3523
🏁 Script executed:
#!/bin/bash
# Show the full BettingController to see all endpoints mentioned in review
cat backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.javaLength of output: 5591
🏁 Script executed:
#!/bin/bash
# Check for any Jackson configuration that might control serialization
rg -n '@JsonInclude|@JsonProperty|@JsonView|@JsonSerialize' backend/src/main/java/org/sejongisc/backend/betting/ -A2Length of output: 291
엔티티 객체의 직접 반환에 따른 아키텍처 위험 개선 필요
현재 모든 엔드포인트에서 BetRound와 UserBet 엔티티를 직접 반환하고 있습니다. 순환 참조 위험은 현재 설정(unidirectional relationship + LAZY fetch)에서 즉각적인 문제는 아니지만, 다음의 아키텍처적 문제가 존재합니다:
- 영속성 계층과 API 계층의 결합: 엔티티 구조 변경 시 API 스펙이 자동으로 변경됩니다
- 보안 위험: 의도하지 않은 필드(예: 내부 상태, 민감 정보)가 노출될 수 있습니다
- API 버전 관리 어려움: 엔티티 변경 없이 API만 변경하기 어렵습니다
- 직렬화 동작의 불확실성: 미래 fetch 전략 변경 시 예상치 못한 동작 발생 가능
각 엔드포인트에 맞는 DTO 클래스를 작성하여 응답 데이터를 제어하기를 권장합니다 (50-60, 74-77, 94-101, 138-144 라인).
🤖 Prompt for AI Agents
backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java
(around lines 50-60, and also adjust endpoints at 74-77, 94-101, 138-144):
currently the controller returns JPA entities (BetRound/UserBet) directly;
replace those with dedicated response DTOs to decouple API from persistence.
Create BetRoundDto and UserBetDto containing only the fields the API should
expose, implement simple mapping from entity-to-DTO (in service or controller)
or use a mapper (MapStruct/constructors), change method signatures to return
ResponseEntity<BetRoundDto> / ResponseEntity<List<UserBetDto>> (or
Optional-wrapped DTO) and map Optional<Entity> to Optional<Dto> before building
the ResponseEntity, and ensure no lazy/internal/sensitive fields are exposed;
update all referenced endpoints at the listed line ranges to use the DTOs and
mapping.
| public PriceResponse getPriceData() { | ||
| List<PriceData> priceList = priceDataRepository.findAll(); | ||
| if (priceList.isEmpty()) { | ||
| throw new CustomException(ErrorCode.STOCK_NOT_FOUND); | ||
| } | ||
| // TODO : 가중치 랜덤설정 | ||
| return stocks.get(random.nextInt(stocks.size())); | ||
|
|
||
| PriceData price = priceList.get(random.nextInt(priceList.size())); | ||
|
|
||
| return PriceResponse.builder() | ||
| .name(price.getTicker()) | ||
| .symbol(price.getTicker()) | ||
| .market(MarketType.US) | ||
| .previousClosePrice(price.getClosePrice()) | ||
| .settleClosePrice(price.getAdjustedClose()) | ||
| .build(); | ||
| } | ||
|
|
||
| /** | ||
| * 무료 베팅 가능 여부 (20% 확률) | ||
| */ | ||
| public boolean setAllowFree() { | ||
| return random.nextDouble() < 0.2; | ||
| } | ||
|
|
||
| /** | ||
| * 사용자의 전체 베팅 내역 조회 | ||
| */ | ||
| public List<UserBet> getAllMyBets(UUID userId) { | ||
| // TODO : 필요 시 필터링, 정렬, 검색 로직 추가 | ||
| return userBetRepository.findAllByUserIdOrderByRound_SettleAtDesc(userId); | ||
| } | ||
|
|
||
| /** | ||
| * 새로운 베팅 라운드 생성 | ||
| */ | ||
| public void createBetRound(Scope scope) { | ||
|
|
||
| LocalDateTime now = LocalDateTime.now(); | ||
|
|
||
| Stock stock = getStock(); | ||
| PriceResponse price = getPriceData(); | ||
|
|
||
| BetRound betRound = BetRound.builder() | ||
| .scope(scope) | ||
| .title(now.toLocalDate() + " " + stock.getName() + " " + scope.name() + " 라운드") | ||
| .symbol(stock.getSymbol()) | ||
| .title(now.toLocalDate() + " " + price.getName() + " " + scope.name() + " 라운드") | ||
| .symbol(price.getSymbol()) | ||
| .allowFree(setAllowFree()) | ||
| .openAt(scope.getOpenAt(now)) | ||
| .lockAt(scope.getLockAt(now)) | ||
| .market(stock.getMarket()) | ||
| .previousClosePrice(stock.getPreviousClosePrice()) | ||
| .market(price.getMarket()) | ||
| .previousClosePrice(price.getPreviousClosePrice()) | ||
| .build(); |
There was a problem hiding this comment.
무작위 PriceData 선택이 최신 시세와 불일치합니다
getPriceData()가 priceDataRepository.findAll() 결과에서 임의로 한 건을 뽑는데, PriceData는 (ticker, date) 기반 시계열이라 과거 날짜 레코드를 선택할 수 있습니다. 이후 정산에서는 findFirstByTickerOrderByDateDesc로 최신 종가를 사용하므로, 라운드 생성 시 저장한 기준가와 정산 시점 가격이 전혀 다른 날짜에서 나온 값이 됩니다. 이는 베팅 결과를 왜곡시키는 치명적인 오류입니다.
무작위로 티커만 선택하고, 그 티커의 최신 데이터를 findFirstByTickerOrderByDateDesc로 조회하거나, 아예 레포지토리에서 “각 티커의 최신 레코드”만 가져오는 쿼리로 교체해 주세요.
🤖 Prompt for AI Agents
In
backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java
around lines 53-101, getPriceData() currently picks a random PriceData from
priceDataRepository.findAll(), which can return old dated records and cause
mismatch with settlement logic; change it so you first pick a random ticker and
then load that ticker's latest record via
priceDataRepository.findFirstByTickerOrderByDateDesc(ticker) (or replace
findAll() with a repository query that returns only the latest record per
ticker), and ensure you handle the empty/absent case by throwing the existing
CustomException(ErrorCode.STOCK_NOT_FOUND).
| //package org.sejongisc.backend.betting.service; | ||
| // | ||
| //import org.junit.jupiter.api.BeforeEach; | ||
| //import org.junit.jupiter.api.DisplayName; | ||
| //import org.junit.jupiter.api.Test; | ||
| //import org.mockito.ArgumentCaptor; | ||
| //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 java.math.BigDecimal; | ||
| //import java.time.LocalDateTime; | ||
| //import java.util.*; | ||
| // | ||
| //import static org.assertj.core.api.Assertions.assertThat; | ||
| //import static org.junit.jupiter.api.Assertions.assertThrows; | ||
| //import static org.mockito.Mockito.*; | ||
| // | ||
| //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); | ||
| // userBetRepository = mock(UserBetRepository.class); | ||
| // pointHistoryService = mock(PointHistoryService.class); | ||
| // | ||
| // bettingService = new BettingService( | ||
| // betRoundRepository, | ||
| // stockRepository, | ||
| // userBetRepository, | ||
| // pointHistoryService | ||
| // ); | ||
| // | ||
| // userId = UUID.randomUUID(); | ||
| // roundId = UUID.randomUUID(); | ||
| // } | ||
| // | ||
| // // --------------------- | ||
| // // Stock / util tests | ||
| // // --------------------- | ||
| // @Test | ||
| // void getStock_빈리스트면_예외발생() { | ||
| // when(stockRepository.findAll()).thenReturn(List.of()); | ||
| // | ||
| // CustomException ex = assertThrows(CustomException.class, | ||
| // () -> bettingService.getStock()); | ||
| // | ||
| // assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.STOCK_NOT_FOUND); | ||
| // verify(stockRepository, times(1)).findAll(); | ||
| // } | ||
| // | ||
| // @Test | ||
| // void getStock_리스트있으면_하나반환() { | ||
| // Stock s1 = Stock.builder() | ||
| // .name("삼성전자") | ||
| // .symbol("005930") | ||
| // .market(MarketType.KOREA) | ||
| // .previousClosePrice(BigDecimal.valueOf(85400)) | ||
| // .build(); | ||
| // | ||
| // Stock s2 = Stock.builder() | ||
| // .name("애플") | ||
| // .symbol("AAPL") | ||
| // .market(MarketType.US) | ||
| // .previousClosePrice(BigDecimal.valueOf(251.65)) | ||
| // .build(); | ||
| // | ||
| // when(stockRepository.findAll()).thenReturn(List.of(s1, s2)); | ||
| // | ||
| // Stock chosen = bettingService.getStock(); | ||
| // | ||
| // assertThat(List.of(s1, s2)).contains(chosen); | ||
| // verify(stockRepository, times(1)).findAll(); | ||
| // } | ||
| // | ||
| // @Test | ||
| // void setAllowFree_항상_불린값반환() { | ||
| // boolean result = bettingService.setAllowFree(); | ||
| // assertThat(result).isInstanceOf(Boolean.class); | ||
| // } | ||
| // | ||
| // // --------------------- | ||
| // // 조회 관련 테스트 | ||
| // // --------------------- | ||
| // @Test | ||
| // @DisplayName("createBetRound_DAILY_정상저장") | ||
| // void createBetRound_DAILY_정상저장() { | ||
| // // given | ||
| // Stock stock = Stock.builder() | ||
| // .name("삼성전자") | ||
| // .symbol("005930") | ||
| // .market(MarketType.KOREA) | ||
| // .previousClosePrice(BigDecimal.valueOf(85400)) | ||
| // .build(); | ||
| // | ||
| // when(stockRepository.findAll()).thenReturn(List.of(stock)); | ||
| // | ||
| // // when | ||
| // bettingService.createBetRound(Scope.DAILY); | ||
| // | ||
| // // then | ||
| // ArgumentCaptor<BetRound> captor = ArgumentCaptor.forClass(BetRound.class); | ||
| // verify(betRoundRepository, times(1)).save(captor.capture()); | ||
| // BetRound saved = captor.getValue(); | ||
| // | ||
| // assertThat(saved.getScope()).isEqualTo(Scope.DAILY); | ||
| // assertThat(saved.getSymbol()).isEqualTo("005930"); | ||
| // assertThat(saved.getTitle()).contains("삼성전자"); | ||
| // assertThat(saved.isOpen()).isTrue(); // 스케줄러에서 open() 호출 후 저장이라면 true여야 함 | ||
| // } | ||
| // | ||
| // @Test | ||
| // @DisplayName("활성화된 DAILY BetRound 조회 성공") | ||
| // void findActiveRound_Success() { | ||
| // BetRound dailyRound = BetRound.builder() | ||
| // .betRoundID(UUID.randomUUID()) | ||
| // .scope(Scope.DAILY) | ||
| // .status(true) | ||
| // .title("Daily Round") | ||
| // .openAt(LocalDateTime.now()) | ||
| // .build(); | ||
| // | ||
| // when(betRoundRepository.findByStatusTrueAndScope(Scope.DAILY)) | ||
| // .thenReturn(Optional.of(dailyRound)); | ||
| // | ||
| // Optional<BetRound> result = bettingService.getActiveRound(Scope.DAILY); | ||
| // | ||
| // assertThat(result).isPresent(); | ||
| // assertThat(result.get().getScope()).isEqualTo(Scope.DAILY); | ||
| // assertThat(result.get().isOpen()).isTrue(); | ||
| // verify(betRoundRepository, times(1)).findByStatusTrueAndScope(Scope.DAILY); | ||
| // } | ||
| // | ||
| // @Test | ||
| // @DisplayName("활성화된 BetRound가 없을 때 빈 Optional 반환") | ||
| // void findActiveRound_NotFound() { | ||
| // when(betRoundRepository.findByStatusTrueAndScope(Scope.DAILY)) | ||
| // .thenReturn(Optional.empty()); | ||
| // | ||
| // Optional<BetRound> result = bettingService.getActiveRound(Scope.DAILY); | ||
| // | ||
| // assertThat(result).isEmpty(); | ||
| // verify(betRoundRepository, times(1)).findByStatusTrueAndScope(Scope.DAILY); | ||
| // } | ||
| // | ||
| // @Test | ||
| // @DisplayName("모든 BetRound 최신순 조회 성공") | ||
| // void getAllBetRounds_Success() { | ||
| // List<BetRound> betRounds = List.of( | ||
| // BetRound.builder() | ||
| // .betRoundID(UUID.randomUUID()) | ||
| // .scope(Scope.DAILY) | ||
| // .status(true) | ||
| // .title("Daily Round") | ||
| // .openAt(LocalDateTime.now()) | ||
| // .build(), | ||
| // BetRound.builder() | ||
| // .betRoundID(UUID.randomUUID()) | ||
| // .scope(Scope.WEEKLY) | ||
| // .status(false) | ||
| // .title("Weekly Round") | ||
| // .openAt(LocalDateTime.now().minusDays(1)) | ||
| // .build() | ||
| // ); | ||
| // | ||
| // when(betRoundRepository.findAllByOrderBySettleAtDesc()) | ||
| // .thenReturn(betRounds); | ||
| // | ||
| // List<BetRound> results = bettingService.getAllBetRounds(); | ||
| // | ||
| // assertThat(results).hasSize(2); | ||
| // assertThat(results.get(0).getScope()).isEqualTo(Scope.DAILY); | ||
| // assertThat(results.get(1).getScope()).isEqualTo(Scope.WEEKLY); | ||
| // verify(betRoundRepository, times(1)).findAllByOrderBySettleAtDesc(); | ||
| // } | ||
| // | ||
| // @Test | ||
| // @DisplayName("BetRound가 없을 때 빈 리스트 반환") | ||
| // void getAllBetRounds_Empty() { | ||
| // when(betRoundRepository.findAllByOrderBySettleAtDesc()) | ||
| // .thenReturn(Collections.emptyList()); | ||
| // | ||
| // List<BetRound> results = bettingService.getAllBetRounds(); | ||
| // | ||
| // assertThat(results).isEmpty(); | ||
| // verify(betRoundRepository, times(1)).findAllByOrderBySettleAtDesc(); | ||
| // } | ||
| // | ||
| // @Test | ||
| // @DisplayName("WEEKLY BetRound 조회 성공") | ||
| // void findActiveRound_Weekly_Success() { | ||
| // BetRound weeklyRound = BetRound.builder() | ||
| // .betRoundID(UUID.randomUUID()) | ||
| // .scope(Scope.WEEKLY) | ||
| // .status(true) | ||
| // .title("Weekly Round") | ||
| // .openAt(LocalDateTime.now()) | ||
| // .build(); | ||
| // | ||
| // when(betRoundRepository.findByStatusTrueAndScope(Scope.WEEKLY)) | ||
| // .thenReturn(Optional.of(weeklyRound)); | ||
| // | ||
| // Optional<BetRound> result = bettingService.getActiveRound(Scope.WEEKLY); | ||
| // | ||
| // assertThat(result).isPresent(); | ||
| // assertThat(result.get().getScope()).isEqualTo(Scope.WEEKLY); | ||
| // verify(betRoundRepository, times(1)).findByStatusTrueAndScope(Scope.WEEKLY); | ||
| // } | ||
| // | ||
| // @Test | ||
| // @DisplayName("getAllMyBets() - 유저 ID로 조회 시 Repository 호출 및 결과 반환 확인") | ||
| // void getAllMyBets_Success() { | ||
| // UUID u = UUID.randomUUID(); | ||
| // BetRound round = BetRound.builder() | ||
| // .title("테스트 라운드") | ||
| // .openAt(LocalDateTime.now().minusHours(2)) | ||
| // .lockAt(LocalDateTime.now().plusHours(1)) | ||
| // .settleAt(LocalDateTime.now().plusHours(2)) | ||
| // .build(); | ||
| // | ||
| // UserBet bet1 = UserBet.builder() | ||
| // .userBetId(UUID.randomUUID()) | ||
| // .round(round) | ||
| // .userId(u) | ||
| // .option(BetOption.RISE) | ||
| // .stakePoints(100) | ||
| // .isFree(false) | ||
| // .build(); | ||
| // | ||
| // UserBet bet2 = UserBet.builder() | ||
| // .userBetId(UUID.randomUUID()) | ||
| // .round(round) | ||
| // .userId(u) | ||
| // .option(BetOption.FALL) | ||
| // .stakePoints(50) | ||
| // .isFree(true) | ||
| // .build(); | ||
| // | ||
| // List<UserBet> mockResult = List.of(bet1, bet2); | ||
| // when(userBetRepository.findAllByUserIdOrderByRound_SettleAtDesc(u)) | ||
| // .thenReturn(mockResult); | ||
| // | ||
| // List<UserBet> result = bettingService.getAllMyBets(u); | ||
| // | ||
| // verify(userBetRepository, times(1)) | ||
| // .findAllByUserIdOrderByRound_SettleAtDesc(u); | ||
| // assertThat(result).hasSize(2); | ||
| // assertThat(result.get(0).getUserId()).isEqualTo(u); | ||
| // assertThat(result.get(1).getRound().getTitle()).isEqualTo("테스트 라운드"); | ||
| // } | ||
| // | ||
| // // --------------------- | ||
| // // Bet creation / posting tests | ||
| // // --------------------- | ||
| // 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(false) | ||
| // .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_paid_success") | ||
| // 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_free_success") | ||
| // 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_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_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_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_ROUND_CLOSED); | ||
| // verifyNoInteractions(pointHistoryService); | ||
| // verify(userBetRepository, never()).save(any()); | ||
| // } | ||
| // | ||
| // // --------------------- | ||
| // // cancelUserBet tests | ||
| // // --------------------- | ||
| // @Test | ||
| // @DisplayName("cancelUserBet_paid_refund_and_delete") | ||
| // 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_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_after_lock") | ||
| // 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()); | ||
| // } | ||
| //} |
There was a problem hiding this comment.
테스트 클래스 전체 주석 처리로 인한 회귀 위험
BettingServiceTest의 모든 코드가 주석 처리되어 테스트가 전혀 실행되지 않습니다. 이 상태로는 이번 리팩토링에서 발생할 수 있는 회귀나 정산 로직 변경에 대한 검증이 불가능하며, 이후 수정 시 실패를 조기에 발견할 수 없습니다. 기존 테스트를 현행 로직(PriceData 기반 흐름)에 맞게 업데이트하거나 최소한 대체 검증을 제공해주세요. 주석 제거 없이 머지하는 것은 피해야 합니다.
🤖 Prompt for AI Agents
In
backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTest.java
around lines 1-474, the entire test class is commented out causing no unit tests
to run; restore test coverage by uncommenting the class and its tests, update
the tests to match the current BettingService behavior (adjust setup, mocks, and
assertions to the new PriceData-based flow or any changed method signatures),
reintroduce/verifying necessary mocks for repositories and PointHistoryService,
and run the test suite (mvn test or equivalent) fixing any failing assertions or
compilation errors until all tests pass.
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선사항
제거됨