diff --git a/src/main/java/com/cleanengine/coin/chart/service/ChartSubscriptionService.java b/src/main/java/com/cleanengine/coin/chart/service/ChartSubscriptionService.java index dfa1a879..26d3f177 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/ChartSubscriptionService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/ChartSubscriptionService.java @@ -26,20 +26,28 @@ public class ChartSubscriptionService { 실시간 체결 내역 구독 */ public void subscribeRealTimeTradeRate(String ticker) { + validateTicker(ticker); log.debug("실시간 체결 정보 티커 구독 추가: {}", ticker); realTimeTradeRateSubscribedTickers.add(ticker); } + //구독 해지 public void unsubscribeRealTimeTradeRate(String ticker) { + validateTicker(ticker); log.debug("실시간 체결 정보 티커 구독 해지: {}", ticker); realTimeTradeRateSubscribedTickers.remove(ticker); } + //모든 구독 종목 반환 public Set getAllRealTimeTradeRateSubscribedTickers() { return realTimeTradeRateSubscribedTickers; } + //종목에 대한 구독 여부 public boolean isSubscribedToRealTimeTradeRate(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + return false; // 유효하지 않은 티커는 구독되지 않은 것으로 처리 + } return realTimeTradeRateSubscribedTickers.contains(ticker); } @@ -48,11 +56,13 @@ public boolean isSubscribedToRealTimeTradeRate(String ticker) { * 실시간 OHLC 티커 구독 추가 */ public void subscribeRealTimeOhlc(String ticker) { + validateTicker(ticker); log.debug("실시간 OHLC 티커 구독 추가: {}", ticker); realTimeOhlcSubscribedTickers.add(ticker); } public void unsubscribeRealTimeOhlc(String ticker) { + validateTicker(ticker); log.debug("실시간 OHLC 티커 구독 해지: {}", ticker); realTimeOhlcSubscribedTickers.remove(ticker); } @@ -66,25 +76,45 @@ public Set getAllRealTimeOhlcSubscribedTickers() { } public boolean isSubscribedToRealTimeOhlc(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + return false; // 유효하지 않은 티커는 구독되지 않은 것으로 처리 + } return realTimeOhlcSubscribedTickers.contains(ticker); } + /* 전날 종가 변동률 구독 추가,삭제,조회 */ public void subscribePrevRate(String ticker) { + validateTicker(ticker); log.debug("전날 종가 변동률 티커 구독 추가: {}", ticker); PrevRateSubscribedTickers.add(ticker); } + public void unsubscribePrevRate(String ticker) { + validateTicker(ticker); log.debug("전날 종가 변동률 티커 구독 해지: {}", ticker); PrevRateSubscribedTickers.remove(ticker); } + public Set getAllPrevRateSubscribedTickers(String ticker) { return PrevRateSubscribedTickers; } public boolean isSubscribedToPrevRate(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + return false; // 유효하지 않은 티커는 구독되지 않은 것으로 처리 + } return PrevRateSubscribedTickers.contains(ticker); } + + //CCmap은 null을 허용시키기때문에 null 종목이 들어가도 npe발생안되는 이슈 테스트에서 발견 + //검증 로직 추가 + private void validateTicker(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + throw new IllegalArgumentException("유효하지 않은 티커입니다: " + ticker); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java b/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java index c5174d74..b4e7f17f 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java @@ -6,6 +6,7 @@ import com.cleanengine.coin.trade.entity.Trade; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -18,29 +19,45 @@ public class RealTimeDataPrevRateService { private final RealTimeTradeRepository tradeRepository; public PrevRateDto generatePrevRateData(TradeEventDto currentTrade) { - // 전일 종가 계산 - LocalDateTime today = LocalDateTime.now(); - LocalDateTime yesterdayStart = today.minusDays(1).withHour(0).withMinute(0).withSecond(0); - LocalDateTime yesterdayEnd = today.minusDays(1).withHour(23).withMinute(59).withSecond(59); - log.debug("조회 시간 범위: {} ~ {}", yesterdayStart, yesterdayEnd); + return generatePrevRateData(currentTrade, LocalDateTime.now()); + } + + PrevRateDto generatePrevRateData(TradeEventDto currentTrade, LocalDateTime currentTime) { String ticker = currentTrade.getTicker(); + YesterDay yesterDay = getYesterDay(currentTime); + log.debug("조회 시간 범위: {} ~ {}", yesterDay.yesterdayStart(), yesterDay.yesterdayEnd()); + Trade yesterdayLastTrade = tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( - ticker, yesterdayStart, yesterdayEnd); + ticker, yesterDay.yesterdayStart(), yesterDay.yesterdayEnd()); - if(yesterdayLastTrade == null){ + if (yesterdayLastTrade == null) { log.debug("전일 거래 데이터가 없습니다: {}", ticker); - return new PrevRateDto(ticker, 0.0, currentTrade.getPrice(), 0.0, LocalDateTime.now()); + return new PrevRateDto(ticker, 0.0, currentTrade.getPrice(), 0.0, currentTime); } double prevClose = yesterdayLastTrade.getPrice(); double currentPrice = currentTrade.getPrice(); - double changeRate = ((currentPrice - prevClose) / prevClose) * 100; + double changeRate = getChangeRate(currentPrice, prevClose); return new PrevRateDto( ticker, prevClose, currentPrice, changeRate, - LocalDateTime.now() + currentTime ); } + + static double getChangeRate(double currentPrice, double prevClose) { + return ((currentPrice - prevClose) / prevClose) * 100; + } + + @NotNull + static YesterDay getYesterDay(LocalDateTime today) { + LocalDateTime yesterdayStart = today.minusDays(1).withHour(0).withMinute(0).withSecond(0); + LocalDateTime yesterdayEnd = today.minusDays(1).withHour(23).withMinute(59).withSecond(59); + return new YesterDay(yesterdayStart, yesterdayEnd); + } + + record YesterDay(LocalDateTime yesterdayStart, LocalDateTime yesterdayEnd) { + } } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java b/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java index af7bd463..19298eb1 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java @@ -5,10 +5,10 @@ import com.cleanengine.coin.trade.repository.TradeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import java.time.LocalDateTime; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -31,56 +31,90 @@ public class RealTimeOhlcService { */ public RealTimeOhlcDto getRealTimeOhlc(String ticker) { try { - // 현재 시간 LocalDateTime now = LocalDateTime.now(); - // 마지막 처리 시간 (없으면 현재 시간에서 1초 전) - LocalDateTime lastProcessedTime = lastProcessedTimeMap.getOrDefault( - ticker, now.minusSeconds(1)); + // 시간 범위 계산 + TimeRange timeRange = calculateTimeRange(ticker, now); - // 1초 전부터 현재까지의 데이터 조회 - List recentTrades = tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - ticker, - lastProcessedTime, - now - ); + // 거래 데이터 조회 및 전처리 + List recentTrades = getProcessedTradeData(ticker, timeRange); - // 시간 순서대로 정렬이 필요하면 뒤집음 - Collections.reverse(recentTrades); - - // 거래 데이터가 없으면 마지막으로 캐싱된 데이터 반환 + // 거래 데이터가 없으면 캐시된 데이터 반환 if (recentTrades.isEmpty()) { - return lastOhlcDataMap.getOrDefault(ticker, null); + return getCachedData(ticker); } - // 새로운 마지막 처리 시간 업데이트 - lastProcessedTimeMap.put(ticker, now); - - // OHLC 계산 - Double open = recentTrades.get(0).getPrice(); - Double high = recentTrades.stream().mapToDouble(Trade::getPrice).max().orElse(0.0); - Double low = recentTrades.stream().mapToDouble(Trade::getPrice).min().orElse(0.0); - Double close = recentTrades.get(recentTrades.size() - 1).getPrice(); - Double volume = recentTrades.stream().mapToDouble(Trade::getSize).sum(); - - // RealTimeOhlcDto 생성 - RealTimeOhlcDto ohlcData = new RealTimeOhlcDto( - ticker, - now, - open, - high, - low, - close, - volume - ); - - // 캐시에 저장 - lastOhlcDataMap.put(ticker, ohlcData); + calculateOhlcv ohlcv = getCalculateOhlcv(recentTrades); + + RealTimeOhlcDto ohlcData = createOhlcDto(ticker, now, ohlcv); + + // 캐시 업데이트 + updateCache(ticker, now, ohlcData); return ohlcData; } catch (Exception e) { log.error("실시간 OHLC 데이터 생성 중 오류: {}", e.getMessage(), e); - return lastOhlcDataMap.getOrDefault(ticker, null); + return getCachedData(ticker); } } + + // 시간 범위 계산 + TimeRange calculateTimeRange(String ticker, LocalDateTime now) { + LocalDateTime lastProcessedTime = lastProcessedTimeMap.getOrDefault( + ticker, now.minusSeconds(1)); + return new TimeRange(lastProcessedTime, now); + } + + // 거래 데이터 조회 및 전처리 + List getProcessedTradeData(String ticker, TimeRange timeRange) { + return tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + ticker, + timeRange.start(), + timeRange.end() + ); + } + + // 캐시 업데이트 + void updateCache(String ticker, LocalDateTime now, RealTimeOhlcDto ohlcData) { + lastProcessedTimeMap.put(ticker, now); + lastOhlcDataMap.put(ticker, ohlcData); + } + + // 캐시된 데이터 조회 + RealTimeOhlcDto getCachedData(String ticker) { + return lastOhlcDataMap.getOrDefault(ticker, null); + } + + // DTO 생성 + RealTimeOhlcDto createOhlcDto(String ticker, LocalDateTime timestamp, calculateOhlcv ohlcv) { + return new RealTimeOhlcDto( + ticker, + timestamp, + ohlcv.open(), + ohlcv.high(), + ohlcv.low(), + ohlcv.close(), + ohlcv.volume() + ); + } + + // OHLCV 계산 메서드 + @NotNull + static calculateOhlcv getCalculateOhlcv(List trades) { + // trades는 시간 오름차순 정렬되어 있음 + Double open = trades.getFirst().getPrice(); // 첫 번째(가장 오래된) = Open ✅ + Double close = trades.getLast().getPrice(); // 마지막(가장 최근) = Close ✅ + Double high = trades.stream().mapToDouble(Trade::getPrice).max().orElse(0.0); + Double low = trades.stream().mapToDouble(Trade::getPrice).min().orElse(0.0); + Double volume = trades.stream().mapToDouble(Trade::getSize).sum(); + + return new calculateOhlcv(open, high, low, close, volume); + } + + + + + record TimeRange(LocalDateTime start, LocalDateTime end) {} + + record calculateOhlcv(Double open, Double high, Double low, Double close, Double volume) {} } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/RealTimeTradeService.java b/src/main/java/com/cleanengine/coin/chart/service/RealTimeTradeService.java index 95c6e809..54c6eb57 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeTradeService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeTradeService.java @@ -1,3 +1,4 @@ + package com.cleanengine.coin.chart.service; import com.cleanengine.coin.chart.dto.RealTimeDataDto; @@ -7,12 +8,10 @@ import org.springframework.stereotype.Service; import java.util.UUID; - import java.time.LocalDateTime; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -//종목 체결내역 서비스 변동률 @Service @RequiredArgsConstructor @Slf4j @@ -23,80 +22,134 @@ public class RealTimeTradeService { //이벤트 Dto 받아서 체결내역에 필요한 데이터들을 보내주는것 public RealTimeDataDto generateRealTimeData(TradeEventDto tradeEventDto) { - // 최신 거래 이벤트 데이터 조회 - if (tradeEventDto == null) { - log.debug("실시간 거래 데이터가 존재하지않습니다: {}", (Object) null); - return new RealTimeDataDto(null, 0, 0, 0, LocalDateTime.now(), UUID.randomUUID().toString()); + try { + TradeInfo currentTradeInfo = extractTradeInfo(tradeEventDto); + + ChangeRateResult changeRateResult = calculateChangeRate(tradeEventDto, currentTradeInfo); + + updateTradeCache(tradeEventDto, changeRateResult); + + return createRealTimeDataDto(currentTradeInfo, changeRateResult.changeRate()); + + } catch (Exception e) { + log.error("실시간 데이터 생성 중 오류: {}", e.getMessage(), e); + // 기본값으로 DTO 생성 + TradeInfo currentTradeInfo = extractTradeInfo(tradeEventDto); + return createRealTimeDataDto(currentTradeInfo, 0.0); + } + } + + // 거래 정보 추출 + TradeInfo extractTradeInfo(TradeEventDto tradeEventDto) { + return new TradeInfo( + tradeEventDto.getTicker(), + tradeEventDto.getPrice(), + tradeEventDto.getSize(), + tradeEventDto.getTimestamp() + ); + } + + // 변동률 계산 + ChangeRateResult calculateChangeRate(TradeEventDto currentTrade, TradeInfo currentTradeInfo) { + TradeEventDto previousTrade = previousTradeMap.get(currentTradeInfo.ticker()); + + logTradeComparison(currentTrade, previousTrade); + + if (!shouldCalculateChangeRate(previousTrade, currentTrade)) { + return new ChangeRateResult(0.0, false); + } + + if (!isNewTrade(previousTrade, currentTrade)) { + log.debug("동일한 타임스탬프의 거래 데이터가 다시 수신됨: {}", currentTrade.getTimestamp()); + return new ChangeRateResult(0.0, false); + } + + double changeRate = getChangeRate(currentTradeInfo.price(), previousTrade.getPrice()); + log.debug("변동률 계산: 현재가={}, 이전가={}, 변동률={}%", + currentTradeInfo.price(), previousTrade.getPrice(), changeRate); + + return new ChangeRateResult(changeRate, true); + } + + // 변동률 계산 조건 검사 + boolean shouldCalculateChangeRate(TradeEventDto previousTrade, TradeEventDto currentTrade) { + if (previousTrade == null) { + log.debug("이전 거래 정보가 없어 변동률을 0으로 설정: {}", currentTrade.getTicker()); + return false; + } + + if (previousTrade.getPrice() <= 0) { + log.debug("이전 거래 가격이 유효하지 않음: {}", previousTrade.getPrice()); + return false; + } + + if (previousTrade == currentTrade) { + log.debug("동일한 거래 객체가 다시 수신됨 (참조 동일): {}", currentTrade.getTicker()); + return false; } - // 현재 가격 및 시간 정보 추출 - String ticker = tradeEventDto.getTicker(); - double currentPrice = tradeEventDto.getPrice(); - double currentSize = tradeEventDto.getSize(); - LocalDateTime currentTime = tradeEventDto.getTimestamp(); + return true; + } + + // 새로운 거래인지 판단 + boolean isNewTrade(TradeEventDto previousTrade, TradeEventDto currentTrade) { + if (previousTrade.getTimestamp() == null || currentTrade.getTimestamp() == null) { + return true; + } + return !previousTrade.getTimestamp().equals(currentTrade.getTimestamp()); + } - // 변동률 계산 - double changeRate = 0.0; - TradeEventDto previousTrade = previousTradeMap.get(ticker); - //참조 오류로 동일한 객체를 보고있어서 변동률 계산에 오류가 발생 + // 거래 비교 로그 + void logTradeComparison(TradeEventDto currentTrade, TradeEventDto previousTrade) { log.debug("타임스탬프 비교 - 현재: {}, 이전: {}, 동일객체: {}", - tradeEventDto.getTimestamp(), + currentTrade.getTimestamp(), previousTrade != null ? previousTrade.getTimestamp() : "없음", - previousTrade == tradeEventDto); - - // 이전 거래가 있고, 새로운 거래 데이터인 경우에만 변동률 계산 - if (previousTrade != null && previousTrade.getPrice() > 0 && previousTrade != tradeEventDto) { - // 타임스탬프 비교로 새로운 거래인지 확인 - if (previousTrade.getTimestamp() == null || tradeEventDto.getTimestamp() == null || - !previousTrade.getTimestamp().equals(tradeEventDto.getTimestamp())) { - - double previousPrice = previousTrade.getPrice(); - //SRP를 위한 메서드 분리 - changeRate = getChangeRate(currentPrice, previousPrice); - log.debug("변동률 계산: 현재가={}, 이전가={}, 변동률={}%", - currentPrice, previousPrice, changeRate); - - // 새로운 거래 데이터 저장 - previousTradeMap.put(ticker, new TradeEventDto( - tradeEventDto.getTicker(), - tradeEventDto.getSize(), - tradeEventDto.getPrice(), - tradeEventDto.getTimestamp() - )); - } else { - log.debug("동일한 타임스탬프의 거래 데이터가 다시 수신됨: {}", tradeEventDto.getTimestamp()); - } - } else { - if (previousTrade == null) { - log.debug("이전 거래 정보가 없어 변동률을 0으로 설정: {}", ticker); - // 첫 거래 데이터 저장 (복사본 저장) - previousTradeMap.put(ticker, new TradeEventDto( - tradeEventDto.getTicker(), - tradeEventDto.getSize(), - tradeEventDto.getPrice(), - tradeEventDto.getTimestamp() - )); - } else if (previousTrade == tradeEventDto) { - log.debug("동일한 거래 객체가 다시 수신됨 (참조 동일): {}", ticker); - } + previousTrade == currentTrade); + } + + // 캐시 업데이트 + void updateTradeCache(TradeEventDto tradeEventDto, ChangeRateResult changeRateResult) { + if (changeRateResult.shouldUpdate() || !previousTradeMap.containsKey(tradeEventDto.getTicker())) { + TradeEventDto cachedTrade = createCachedTradeDto(tradeEventDto); + previousTradeMap.put(tradeEventDto.getTicker(), cachedTrade); } - // RealTimeDataDto 객체 생성 및 반환 + } + + // 캐시용 TradeEventDto 생성 (복사본) + TradeEventDto createCachedTradeDto(TradeEventDto cashDataDto) { + return new TradeEventDto( + cashDataDto.getTicker(), + cashDataDto.getSize(), + cashDataDto.getPrice(), + cashDataDto.getTimestamp() + ); + } + + // RealTimeDataDto 생성 + RealTimeDataDto createRealTimeDataDto(TradeInfo tradeInfo, double changeRate) { return new RealTimeDataDto( - ticker, - currentSize, - currentPrice, + tradeInfo.ticker(), + tradeInfo.size(), + tradeInfo.price(), changeRate, - currentTime, - UUID.randomUUID().toString() + tradeInfo.timestamp(), + generateTransactionId() ); } + // 트랜잭션 ID 생성 + String generateTransactionId() { + return UUID.randomUUID().toString(); + } - //SRP + // 변동률 계산 public double getChangeRate(double currentPrice, double previousPrice) { - double changeRate; - changeRate = ((currentPrice - previousPrice) / previousPrice) * 100; - return changeRate; + return ((currentPrice - previousPrice) / previousPrice) * 100; } + // 거래 정보를 담는 record + record TradeInfo(String ticker, double price, double size, LocalDateTime timestamp) {} + + // 변동률 계산 결과를 담는 record + record ChangeRateResult(double changeRate, boolean shouldUpdate) {} } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/WebsocketSendService.java b/src/main/java/com/cleanengine/coin/chart/service/WebsocketSendService.java index 555daee6..0ef1ff8d 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/WebsocketSendService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/WebsocketSendService.java @@ -11,22 +11,42 @@ public class WebsocketSendService { private final SimpMessagingTemplate messagingTemplate; - //직전 데이터등락율 보내는 메세지 형식 public void sendChangeRate(Object data, String ticker) { log.debug("티커 {} 실시간 구독 요청", ticker); - // 티커별 토픽으로 전송 - messagingTemplate.convertAndSend( - "/topic/realTimeTradeRate/" + ticker, data); - log.debug("전송 완료: /topic/realTimeTradeRate/{} -> {}", ticker, data); + String topic = buildTopic("realTimeTradeRate", ticker); + sendMessage(topic, data); + + log.debug("전송 완료: {} -> {}", topic, data); } //전날 종가 변동률로 보내는 메세지 형식 public void sendPrevRate(Object data, String ticker) { log.debug("티커 {} 전일 대비 변동률 보내는 요청", ticker); - messagingTemplate.convertAndSend( - "/topic/prevRate/" + ticker, data); - log.debug("전송 완료: /topic/prevRate/{} -> {}", ticker, data); + + String topic = buildTopic("prevRate", ticker); // 기존 로직 유지 + sendMessage(topic, data); + + log.debug("전송 완료: {} -> {}", topic, data); + } + + // 테스트하기 좋게 분리된 메서드들 + String buildTopic(String topicType, String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + throw new IllegalArgumentException("티커는 비어있을 수 없습니다"); + } + return "/topic/" + topicType + "/" + ticker; + } + + void sendMessage(String topic, Object data) { + if (topic == null || topic.trim().isEmpty()) { + throw new IllegalArgumentException("토픽은 비어있을 수 없습니다"); + } + if (data == null) { + throw new IllegalArgumentException("데이터는 null일 수 없습니다"); + } + + messagingTemplate.convertAndSend(topic, data); } -} +} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImpl.java b/src/main/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImpl.java index 5febc78b..5d1d20e6 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImpl.java +++ b/src/main/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImpl.java @@ -4,11 +4,12 @@ import com.cleanengine.coin.chart.repository.MinuteOhlcDataRepository; import com.cleanengine.coin.trade.entity.Trade; import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; -import java.util.LinkedHashMap; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -21,49 +22,98 @@ public class MinuteOhlcDataServiceImpl implements MinuteOhlcDataService { @Override public List getMinuteOhlcData(String ticker) { - // 1) 해당 티커의 모든 트레이드를 시간 순으로 조회 - List trades = tradeRepository.findByTickerOrderByTradeTimeAsc(ticker); + validateTicker(ticker); + + List trades = getTradeData(ticker); + + if (trades.isEmpty()) { + return List.of(); + } + + Map> groupedByMinute = groupTradesByMinute(trades); + + return convertToOhlcData(ticker, groupedByMinute); + } - // 2) 분 단위로 그룹핑 (tradeTime 을 분 단위로 자르고 순서 유지) - Map> byMinute = trades.stream() + // 입력 검증 + void validateTicker(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + throw new IllegalArgumentException("티커는 비어있을 수 없습니다"); + } + } + + // 거래 데이터 조회 + List getTradeData(String ticker) { + return tradeRepository.findByTickerOrderByTradeTimeAsc(ticker); + } + + // 분 단위 그룹핑 로직 + Map> groupTradesByMinute(List trades) { + return trades.stream() .collect(Collectors.groupingBy( - t -> t.getTradeTime().truncatedTo(ChronoUnit.MINUTES), + this::truncateToMinute, LinkedHashMap::new, Collectors.toList() )); + } + + + LocalDateTime truncateToMinute(Trade trade) { + return trade.getTradeTime().truncatedTo(ChronoUnit.MINUTES); + } - // 3) 각 분 그룹마다 OHLC + **거래량(volume)** 계산 - return byMinute.entrySet().stream() - .map(entry -> { - LocalDateTime minute = entry.getKey(); - List bucket = entry.getValue(); - - double open = bucket.get(0).getPrice(); - double close = bucket.get(bucket.size() - 1).getPrice(); - double high = bucket.stream() - .mapToDouble(Trade::getPrice) - .max() - .orElse(open); - double low = bucket.stream() - .mapToDouble(Trade::getPrice) - .min() - .orElse(open); - - // ← 여기를 바꿔서 “거래량”을 size 필드의 합으로 계산 - double volume = bucket.stream() - .mapToDouble(Trade::getSize) - .sum(); - - return new RealTimeOhlcDto( - ticker, - minute, - open, - high, - low, - close, - volume - ); - }) + // OHLC 데이터 변환 (메인 비즈니스 로직) + List convertToOhlcData(String ticker, Map> groupedByMinute) { + return groupedByMinute.entrySet().stream() + .map(entry -> createOhlcDto(ticker, entry.getKey(), entry.getValue())) .collect(Collectors.toList()); } + + RealTimeOhlcDto createOhlcDto(String ticker, LocalDateTime minute, List trades) { + validateTradeList(trades); + + OhlcData ohlcData = calculateOhlcData(trades); + + return new RealTimeOhlcDto( + ticker, + minute, + ohlcData.open(), + ohlcData.high(), + ohlcData.low(), + ohlcData.close(), + ohlcData.volume() + ); + } + + void validateTradeList(List trades) { + if (trades == null || trades.isEmpty()) { + throw new IllegalArgumentException("거래 데이터가 없습니다"); + } + } + + // OHLC 계산 로직 + @NotNull + static OhlcData calculateOhlcData(List trades) { + double open = trades.get(0).getPrice(); + double close = trades.get(trades.size() - 1).getPrice(); + + double high = trades.stream() + .mapToDouble(Trade::getPrice) + .max() + .orElse(open); + + double low = trades.stream() + .mapToDouble(Trade::getPrice) + .min() + .orElse(open); + + double volume = trades.stream() + .mapToDouble(Trade::getSize) + .sum(); + + return new OhlcData(open, high, low, close, volume); + } + + // OHLC 데이터를 위한 레코드 (불변 객체) + record OhlcData(double open, double high, double low, double close, double volume) {} } \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/ChartSubscriptionServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/ChartSubscriptionServiceTest.java new file mode 100644 index 00000000..a3ac5db7 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/ChartSubscriptionServiceTest.java @@ -0,0 +1,494 @@ +package com.cleanengine.coin.chart.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChartSubscriptionService 단위 테스트") +class ChartSubscriptionServiceTest { + + @InjectMocks + private ChartSubscriptionService service; + + private String testTicker1; + private String testTicker2; + private String testTicker3; + + @BeforeEach + void setUp() { + testTicker1 = "BTC"; + testTicker2 = "ETH"; + testTicker3 = "TRUMP"; + } + + // ===== 실시간 체결 내역 구독 테스트 ===== + @Test + @DisplayName("실시간 체결 정보 구독을 정상적으로 추가한다") + void subscribeRealTimeTradeRate_ValidTicker_AddsSubscription() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).contains(testTicker1); + } + + @Test + @DisplayName("실시간 체결 정보 구독을 정상적으로 해지한다") + void unsubscribeRealTimeTradeRate_SubscribedTicker_RemovesSubscription() { + // given + service.subscribeRealTimeTradeRate(testTicker1); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + + // when + service.unsubscribeRealTimeTradeRate(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isFalse(); + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).doesNotContain(testTicker1); + } + + @Test + @DisplayName("모든 실시간 체결 정보 구독 티커를 올바르게 반환한다") + void getAllRealTimeTradeRateSubscribedTickers_MultipleSubscriptions_ReturnsAllTickers() { + // given + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeTradeRate(testTicker2); + service.subscribeRealTimeTradeRate(testTicker3); + + // when + Set result = service.getAllRealTimeTradeRateSubscribedTickers(); + + // then + assertThat(result).hasSize(3); + assertThat(result).containsExactlyInAnyOrder(testTicker1, testTicker2, testTicker3); + } + + @Test + @DisplayName("실시간 체결 정보 구독 상태를 정확하게 확인한다") + void isSubscribedToRealTimeTradeRate_VariousStates_ReturnsCorrectStatus() { + // given + service.subscribeRealTimeTradeRate(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker2)).isFalse(); + assertThat(service.isSubscribedToRealTimeTradeRate("NONEXISTENT")).isFalse(); + } + + @Test + @DisplayName("동일한 티커를 여러 번 구독해도 중복되지 않는다") + void subscribeRealTimeTradeRate_DuplicateSubscription_NoDuplicates() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeTradeRate(testTicker1); + + // then + Set subscriptions = service.getAllRealTimeTradeRateSubscribedTickers(); + assertThat(subscriptions).hasSize(1); + assertThat(subscriptions).contains(testTicker1); + } + + // ===== 실시간 OHLC 구독 테스트 ===== + @Test + @DisplayName("실시간 OHLC 구독을 정상적으로 추가한다") + void subscribeRealTimeOhlc_ValidTicker_AddsSubscription() { + // when + service.subscribeRealTimeOhlc(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isTrue(); + assertThat(service.getAllRealTimeOhlcSubscribedTickers()).contains(testTicker1); + } + + @Test + @DisplayName("실시간 OHLC 구독을 정상적으로 해지한다") + void unsubscribeRealTimeOhlc_SubscribedTicker_RemovesSubscription() { + // given + service.subscribeRealTimeOhlc(testTicker1); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isTrue(); + + // when + service.unsubscribeRealTimeOhlc(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isFalse(); + assertThat(service.getAllRealTimeOhlcSubscribedTickers()).doesNotContain(testTicker1); + } + + @Test + @DisplayName("모든 실시간 OHLC 구독 티커를 올바르게 반환한다") + void getAllRealTimeOhlcSubscribedTickers_MultipleSubscriptions_ReturnsAllTickers() { + // given + service.subscribeRealTimeOhlc(testTicker1); + service.subscribeRealTimeOhlc(testTicker2); + + // when + Set result = service.getAllRealTimeOhlcSubscribedTickers(); + + // then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder(testTicker1, testTicker2); + } + + @Test + @DisplayName("실시간 OHLC 구독 상태를 정확하게 확인한다") + void isSubscribedToRealTimeOhlc_VariousStates_ReturnsCorrectStatus() { + // given + service.subscribeRealTimeOhlc(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker2)).isFalse(); + } + + @Test + @DisplayName("동일한 티커를 여러 번 OHLC 구독해도 중복되지 않는다") + void subscribeRealTimeOhlc_DuplicateSubscription_NoDuplicates() { + // when + service.subscribeRealTimeOhlc(testTicker1); + service.subscribeRealTimeOhlc(testTicker1); + + // then + Set subscriptions = service.getAllRealTimeOhlcSubscribedTickers(); + assertThat(subscriptions).hasSize(1); + assertThat(subscriptions).contains(testTicker1); + } + + // ===== 전날 종가 변동률 구독 테스트 ===== + @Test + @DisplayName("전날 종가 변동률 구독을 정상적으로 추가한다") + void subscribePrevRate_ValidTicker_AddsSubscription() { + // when + service.subscribePrevRate(testTicker1); + + // then + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + assertThat(service.getAllPrevRateSubscribedTickers(testTicker1)).contains(testTicker1); + } + + @Test + @DisplayName("전날 종가 변동률 구독을 정상적으로 해지한다") + void unsubscribePrevRate_SubscribedTicker_RemovesSubscription() { + // given + service.subscribePrevRate(testTicker1); + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + + // when + service.unsubscribePrevRate(testTicker1); + + // then + assertThat(service.isSubscribedToPrevRate(testTicker1)).isFalse(); + assertThat(service.getAllPrevRateSubscribedTickers(testTicker1)).doesNotContain(testTicker1); + } + + @Test + @DisplayName("모든 전날 종가 변동률 구독 티커를 올바르게 반환한다") + void getAllPrevRateSubscribedTickers_MultipleSubscriptions_ReturnsAllTickers() { + // given + service.subscribePrevRate(testTicker1); + service.subscribePrevRate(testTicker2); + + // when + Set result = service.getAllPrevRateSubscribedTickers("irrelevant"); // 파라미터는 무시됨 + + // then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder(testTicker1, testTicker2); + } + + @Test + @DisplayName("전날 종가 변동률 구독 상태를 정확하게 확인한다") + void isSubscribedToPrevRate_VariousStates_ReturnsCorrectStatus() { + // given + service.subscribePrevRate(testTicker1); + + // then + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + assertThat(service.isSubscribedToPrevRate(testTicker2)).isFalse(); + } + + @Test + @DisplayName("동일한 티커를 여러 번 전날 종가 구독해도 중복되지 않는다") + void subscribePrevRate_DuplicateSubscription_NoDuplicates() { + // when + service.subscribePrevRate(testTicker1); + service.subscribePrevRate(testTicker1); + + // then + Set subscriptions = service.getAllPrevRateSubscribedTickers(testTicker1); + assertThat(subscriptions).hasSize(1); + assertThat(subscriptions).contains(testTicker1); + } + + // ===== 혼합 시나리오 테스트 ===== + @Test + @DisplayName("서로 다른 구독 타입은 독립적으로 관리된다") + void multipleSubscriptionTypes_IndependentManagement() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeOhlc(testTicker1); + service.subscribePrevRate(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isTrue(); + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + + // when - OHLC만 해지 + service.unsubscribeRealTimeOhlc(testTicker1); + + // then - 다른 구독은 유지됨 + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isFalse(); + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + } + + @Test + @DisplayName("각 구독 타입별로 다른 티커를 구독할 수 있다") + void differentTickersPerSubscriptionType() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeOhlc(testTicker2); + service.subscribePrevRate(testTicker3); + + // then + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).containsOnly(testTicker1); + assertThat(service.getAllRealTimeOhlcSubscribedTickers()).containsOnly(testTicker2); + assertThat(service.getAllPrevRateSubscribedTickers("irrelevant")).containsOnly(testTicker3); + } + + @Test + @DisplayName("대량의 티커 구독을 효율적으로 처리한다") + void bulkSubscriptions_EfficientHandling() { + // given + String[] tickers = new String[100]; + for (int i = 0; i < 100; i++) { + tickers[i] = "TICKER_" + i; + } + + // when + for (String ticker : tickers) { + service.subscribeRealTimeTradeRate(ticker); + service.subscribeRealTimeOhlc(ticker); + service.subscribePrevRate(ticker); + } + + // then + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).hasSize(100); + assertThat(service.getAllRealTimeOhlcSubscribedTickers()).hasSize(100); + assertThat(service.getAllPrevRateSubscribedTickers("irrelevant")).hasSize(100); + + // 특정 티커들이 모든 타입에 구독되어 있는지 확인 + assertThat(service.isSubscribedToRealTimeTradeRate("TICKER_50")).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc("TICKER_50")).isTrue(); + assertThat(service.isSubscribedToPrevRate("TICKER_50")).isTrue(); + } + + // ===== 입력 검증 테스트 ===== + @Test + @DisplayName("null 티커로 구독 시 예외가 발생한다") + void subscribeWithNullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.subscribeRealTimeTradeRate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.subscribeRealTimeOhlc(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.subscribePrevRate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + } + + @Test + @DisplayName("빈 문자열 티커로 구독 시 예외가 발생한다") + void subscribeWithEmptyTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.subscribeRealTimeTradeRate("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.subscribeRealTimeOhlc(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.subscribePrevRate("\t\n")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + } + + @Test + @DisplayName("null 티커로 구독 해지 시 예외가 발생한다") + void unsubscribeWithNullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.unsubscribeRealTimeTradeRate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.unsubscribeRealTimeOhlc(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.unsubscribePrevRate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + } + + @Test + @DisplayName("유효하지 않은 티커의 구독 상태 확인 시 false를 반환한다") + void isSubscribedWithInvalidTicker_ReturnsFalse() { + // when & then + assertThat(service.isSubscribedToRealTimeTradeRate(null)).isFalse(); + assertThat(service.isSubscribedToRealTimeTradeRate("")).isFalse(); + assertThat(service.isSubscribedToRealTimeTradeRate(" ")).isFalse(); + + assertThat(service.isSubscribedToRealTimeOhlc(null)).isFalse(); + assertThat(service.isSubscribedToRealTimeOhlc("")).isFalse(); + assertThat(service.isSubscribedToRealTimeOhlc(" ")).isFalse(); + + assertThat(service.isSubscribedToPrevRate(null)).isFalse(); + assertThat(service.isSubscribedToPrevRate("")).isFalse(); + assertThat(service.isSubscribedToPrevRate(" ")).isFalse(); + } + + // ===== 엣지 케이스 테스트 ===== + @Test + @DisplayName("공백이 포함된 유효한 티커는 정상적으로 처리된다") + void subscribeWithValidTickerContainingSpaces_HandledCorrectly() { + // given + String tickerWithSpaces = " BTC "; + + // when + service.subscribeRealTimeTradeRate(tickerWithSpaces); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(tickerWithSpaces)).isTrue(); + assertThat(service.isSubscribedToRealTimeTradeRate("BTC")).isFalse(); // 공백 포함은 다른 키 + } + + @Test + @DisplayName("대소문자가 다른 티커는 서로 다른 구독으로 처리된다") + void subscribeWithDifferentCase_TreatedAsDifferent() { + // when + service.subscribeRealTimeTradeRate("BTC"); + service.subscribeRealTimeTradeRate("btc"); + service.subscribeRealTimeTradeRate("Btc"); + + // then + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).hasSize(3); + assertThat(service.isSubscribedToRealTimeTradeRate("BTC")).isTrue(); + assertThat(service.isSubscribedToRealTimeTradeRate("btc")).isTrue(); + assertThat(service.isSubscribedToRealTimeTradeRate("Btc")).isTrue(); + } + + @Test + @DisplayName("특수 문자가 포함된 티커도 정상적으로 처리된다") + void subscribeWithSpecialCharacters_HandledCorrectly() { + // given + String specialTicker1 = "BTC-USD"; + String specialTicker2 = "ETH/USDT"; + String specialTicker3 = "DOT_BTC"; + + // when + service.subscribeRealTimeTradeRate(specialTicker1); + service.subscribeRealTimeOhlc(specialTicker2); + service.subscribePrevRate(specialTicker3); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(specialTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc(specialTicker2)).isTrue(); + assertThat(service.isSubscribedToPrevRate(specialTicker3)).isTrue(); + } + + // ===== 동시성 테스트 (단위 테스트 수준) ===== + @Test + @DisplayName("동일한 구독 타입에서 여러 티커를 동시에 관리할 수 있다") + void concurrentTickerManagement_SameSubscriptionType() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeTradeRate(testTicker2); + service.subscribeRealTimeTradeRate(testTicker3); + + // then + Set subscriptions = service.getAllRealTimeTradeRateSubscribedTickers(); + assertThat(subscriptions).hasSize(3); + assertThat(subscriptions).containsExactlyInAnyOrder(testTicker1, testTicker2, testTicker3); + + // when - 일부 해지 + service.unsubscribeRealTimeTradeRate(testTicker2); + + // then + Set updatedSubscriptions = service.getAllRealTimeTradeRateSubscribedTickers(); + assertThat(updatedSubscriptions).hasSize(2); + assertThat(updatedSubscriptions).containsExactlyInAnyOrder(testTicker1, testTicker3); + assertThat(updatedSubscriptions).doesNotContain(testTicker2); + } + + // ===== 메서드 시그니처 이슈 테스트 ===== + @Test + @DisplayName("getAllPrevRateSubscribedTickers 메서드의 파라미터는 실제로 사용되지 않는다") + void getAllPrevRateSubscribedTickers_ParameterNotUsed() { + // given + service.subscribePrevRate(testTicker1); + service.subscribePrevRate(testTicker2); + + // when - 다른 파라미터로 호출해도 같은 결과 + Set result1 = service.getAllPrevRateSubscribedTickers(testTicker1); + Set result2 = service.getAllPrevRateSubscribedTickers(testTicker2); + Set result3 = service.getAllPrevRateSubscribedTickers("NONEXISTENT"); + + // then - 모든 호출이 동일한 결과 반환 + assertThat(result1).isEqualTo(result2); + assertThat(result2).isEqualTo(result3); + assertThat(result1).containsExactlyInAnyOrder(testTicker1, testTicker2); + } + + // ===== 비즈니스 로직 일관성 테스트 ===== + @Test + @DisplayName("구독과 해지가 순서에 관계없이 일관되게 동작한다") + void subscriptionLifecycle_ConsistentBehavior() { + // 초기 상태 확인 + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isFalse(); + + // 구독 -> 확인 -> 해지 -> 확인 + service.subscribeRealTimeTradeRate(testTicker1); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + + service.unsubscribeRealTimeTradeRate(testTicker1); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isFalse(); + + // 재구독 + service.subscribeRealTimeTradeRate(testTicker1); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 구독을 해지해도 예외가 발생하지 않는다") + void unsubscribeNonExistentSubscription_NoException() { + // when & then - 예외 발생하지 않음 + assertThatCode(() -> { + service.unsubscribeRealTimeTradeRate(testTicker1); + service.unsubscribeRealTimeOhlc(testTicker2); + service.unsubscribePrevRate(testTicker3); + }).doesNotThrowAnyException(); + + // 구독 상태는 여전히 false + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isFalse(); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker2)).isFalse(); + assertThat(service.isSubscribedToPrevRate(testTicker3)).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java new file mode 100644 index 00000000..e56d9800 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java @@ -0,0 +1,177 @@ +package com.cleanengine.coin.chart.service; + +import com.cleanengine.coin.chart.dto.PrevRateDto; +import com.cleanengine.coin.chart.dto.TradeEventDto; +import com.cleanengine.coin.chart.repository.RealTimeTradeRepository; +import com.cleanengine.coin.trade.entity.Trade; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RealTimeDataPrevRateService 테스트") +class RealTimeDataPrevRateServiceTest { + + @Mock + private RealTimeTradeRepository tradeRepository; + + @InjectMocks + private RealTimeDataPrevRateService service; + + private TradeEventDto tradeEventDto; + private LocalDateTime currentTime; + private Trade mockTrade; + + @BeforeEach + void setUp() { + tradeEventDto = new TradeEventDto("TRUMP", 0, 150.0, LocalDateTime.now()); + currentTime = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + mockTrade = createTrade(currentTime); // ✅ 이제 100.0 가격으로 생성됨 + } + + @Test + @DisplayName("전일 거래 데이터가 있을 때 정상적으로 PrevRateDto를 생성한다") + void generatePrevRateData_WithYesterdayTrade_Success() { + // given + when(tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( + eq("TRUMP"), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(mockTrade); + + // when + PrevRateDto result = service.generatePrevRateData(tradeEventDto, currentTime); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getCurrentPrice()).isEqualTo(150.0); + assertThat(result.getPrevClose()).isEqualTo(100.0); // ✅ 성공 + assertThat(result.getChangeRate()).isEqualTo(50.0); // (150-100)/100 * 100 + assertThat(result.getTimestamp()).isEqualTo(currentTime); + } + + @Test + @DisplayName("전일 거래 데이터가 없을 때 기본값으로 PrevRateDto를 생성한다") + void generatePrevRateData_WithoutYesterdayTrade_ReturnsDefault() { + // given + when(tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( + eq("TRUMP"), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(null); + + // when + PrevRateDto result = service.generatePrevRateData(tradeEventDto, currentTime); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getCurrentPrice()).isEqualTo(150.0); + assertThat(result.getPrevClose()).isEqualTo(0.0); + assertThat(result.getChangeRate()).isEqualTo(0.0); + assertThat(result.getTimestamp()).isEqualTo(currentTime); + } + + @Test + @DisplayName("현재 시간을 사용하는 오버로드 메서드가 정상 동작한다") + void generatePrevRateData_WithCurrentTime_Success() { + // given + when(tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( + eq("TRUMP"), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(mockTrade); + + // when + PrevRateDto result = service.generatePrevRateData(tradeEventDto); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getCurrentPrice()).isEqualTo(150.0); + assertThat(result.getPrevClose()).isEqualTo(100.0); // ✅ 성공 + } + + @Test + @DisplayName("변화율이 양수일때 제대로 츨력이 되는지") + void getChangeRate_PriceIncrease_CalculatesCorrectly() { + // when + double result = RealTimeDataPrevRateService.getChangeRate(120.0, 100.0); + + // then + assertThat(result).isEqualTo(20.0); // (120-100)/100 * 100 + } + + @Test + @DisplayName("변화율이 음수일때 로직이 정상적으로 작동하는지") + void getChangeRate_PriceDecrease_CalculatesCorrectly() { + // when + double result = RealTimeDataPrevRateService.getChangeRate(80.0, 100.0); + + // then + assertThat(result).isEqualTo(-20.0); // (80-100)/100 * 100 + } + + @Test + @DisplayName("변화율이 0일때 0으로 제대로 출력이 되는지") + void getChangeRate_SamePrice_ReturnsZero() { + // when + double result = RealTimeDataPrevRateService.getChangeRate(100.0, 100.0); + + // then + assertThat(result).isEqualTo(0.0); + } + + @Test + @DisplayName("전일 시간 범위를 올바르게 계산한다") + void getYesterDay_CalculatesCorrectRange() { + // given + LocalDateTime today = LocalDateTime.of(2024, 1, 15, 14, 30, 45); + + // when + RealTimeDataPrevRateService.YesterDay result = RealTimeDataPrevRateService.getYesterDay(today); + + // then + assertThat(result.yesterdayStart()).isEqualTo(LocalDateTime.of(2024, 1, 14, 0, 0, 0)); + assertThat(result.yesterdayEnd()).isEqualTo(LocalDateTime.of(2024, 1, 14, 23, 59, 59)); + } + + @Test + @DisplayName("YesterDay 레코드가 올바르게 동작한다") + void yesterDayRecord_WorksCorrectly() { + // given + LocalDateTime start = LocalDateTime.of(2024, 1, 14, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2024, 1, 14, 23, 59, 59); + + // when + RealTimeDataPrevRateService.YesterDay yesterDay = new RealTimeDataPrevRateService.YesterDay(start, end); + + // then + assertThat(yesterDay.yesterdayStart()).isEqualTo(start); + assertThat(yesterDay.yesterdayEnd()).isEqualTo(end); + assertThat(yesterDay.toString()).contains("2024-01-14T00:00"); + assertThat(yesterDay.toString()).contains("2024-01-14T23:59:59"); + } + + @Test + @DisplayName("소수점이 있는 가격에서도 변화율이 정확하게 계산이 된다") + void getChangeRate_WithDecimalPrices_CalculatesCorrectly() { + // when + double result = RealTimeDataPrevRateService.getChangeRate(150.75, 100.50); + + // then + double expected = ((150.75 - 100.50) / 100.50) * 100; + assertThat(result).isEqualTo(expected); + } + + //실제 엔티티를 만들어서 목업엔티티로 사용 + private Trade createTrade(LocalDateTime tradeTime) { + return new Trade(null, "BTC", tradeTime, 1, 2, 100.0, 1.0); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java new file mode 100644 index 00000000..b516bed8 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java @@ -0,0 +1,422 @@ +package com.cleanengine.coin.chart.service; + +import com.cleanengine.coin.chart.dto.RealTimeOhlcDto; +import com.cleanengine.coin.trade.entity.Trade; +import com.cleanengine.coin.trade.repository.TradeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RealTimeOhlcService 단위 테스트") +class RealTimeOhlcServiceTest { + + @Mock + private TradeRepository tradeRepository; + + @InjectMocks + private RealTimeOhlcService service; + + private String validTicker; + private LocalDateTime fixedNow; + private List mockTrades; + + @BeforeEach + void setUp() { + validTicker = "BTC"; + fixedNow = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + mockTrades = createMockTrades(); + } + + // ===== getRealTimeOhlc 통합 테스트 ===== + @Test + @DisplayName("정상적인 거래 데이터로 실시간 OHLC를 생성한다") + void getRealTimeOhlc_WithValidTrades_ReturnsOhlcData() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(mockTrades); + + // when + RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("BTC"); + assertThat(result.getHigh()).isEqualTo(200.0); + assertThat(result.getLow()).isEqualTo(100.0); + assertThat(result.getVolume()).isEqualTo(6.0); // 1+2+3 + } + + @Test + @DisplayName("거래 데이터가 없으면 캐시된 데이터를 반환한다") + void getRealTimeOhlc_NoTrades_ReturnsCachedData() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 캐시에 데이터 미리 저장 + RealTimeOhlcDto cachedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); + service.updateCache(validTicker, fixedNow, cachedData); + + // when + RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + + // then + assertThat(result).isEqualTo(cachedData); + } + + @Test + @DisplayName("거래 데이터도 캐시도 없으면 null을 반환한다") + void getRealTimeOhlc_NoTradesNoCache_ReturnsNull() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // when + RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("예외 발생 시 캐시된 데이터를 반환한다") + void getRealTimeOhlc_ExceptionOccurs_ReturnsCachedData() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenThrow(new RuntimeException("DB 연결 오류")); + + RealTimeOhlcDto cachedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); + service.updateCache(validTicker, fixedNow, cachedData); + + // when + RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + + // then + assertThat(result).isEqualTo(cachedData); + } + + // ===== calculateTimeRange 테스트 ===== + @Test + @DisplayName("첫 번째 호출 시 1초 전부터 현재까지의 범위를 계산한다") + void calculateTimeRange_FirstCall_ReturnsOneSecondRange() { + // when + RealTimeOhlcService.TimeRange result = service.calculateTimeRange(validTicker, fixedNow); + + // then + assertThat(result.start()).isEqualTo(fixedNow.minusSeconds(1)); + assertThat(result.end()).isEqualTo(fixedNow); + } + + @Test + @DisplayName("이전 처리 시간이 있으면 그 시간부터 현재까지의 범위를 계산한다") + void calculateTimeRange_WithPreviousTime_ReturnsCustomRange() { + // given + LocalDateTime previousTime = fixedNow.minusSeconds(5); + + // null 대신 더미 데이터 사용 + RealTimeOhlcDto dummyData = new RealTimeOhlcDto( + validTicker, previousTime, 100.0, 100.0, 100.0, 100.0, 1.0 + ); + service.updateCache(validTicker, previousTime, dummyData); // ✅ 유효한 객체 + + // when + RealTimeOhlcService.TimeRange result = service.calculateTimeRange(validTicker, fixedNow); + + // then + assertThat(result.start()).isEqualTo(previousTime); + assertThat(result.end()).isEqualTo(fixedNow); + } + + // ===== getProcessedTradeData 테스트 ===== + @Test + @DisplayName("빈 거래 데이터는 빈 리스트를 반환한다") + void getProcessedTradeData_EmptyTrades_ReturnsEmptyList() { + // given + RealTimeOhlcService.TimeRange timeRange = new RealTimeOhlcService.TimeRange( + fixedNow.minusSeconds(1), fixedNow); + + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + validTicker, timeRange.start(), timeRange.end())) + .thenReturn(List.of()); + + // when + List result = service.getProcessedTradeData(validTicker, timeRange); + + // then + assertThat(result).isEmpty(); + } + + // ===== updateCache 테스트 ===== + @Test + @DisplayName("캐시를 정상적으로 업데이트한다") + void updateCache_ValidData_UpdatesCorrectly() { + // given + RealTimeOhlcDto ohlcData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 200.0, 50.0, 150.0, 10.0); + + // when + service.updateCache(validTicker, fixedNow, ohlcData); + + // then + RealTimeOhlcDto cachedData = service.getCachedData(validTicker); + assertThat(cachedData).isEqualTo(ohlcData); + + // 시간 범위 계산 시 업데이트된 시간이 사용되는지 확인 + RealTimeOhlcService.TimeRange timeRange = service.calculateTimeRange(validTicker, fixedNow.plusSeconds(5)); + assertThat(timeRange.start()).isEqualTo(fixedNow); + } + + // ===== getCachedData 테스트 ===== + @Test + @DisplayName("캐시된 데이터를 정상적으로 조회한다") + void getCachedData_ExistingData_ReturnsData() { + // given + RealTimeOhlcDto expectedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 200.0, 50.0, 150.0, 10.0); + service.updateCache(validTicker, fixedNow, expectedData); + + // when + RealTimeOhlcDto result = service.getCachedData(validTicker); + + // then + assertThat(result).isEqualTo(expectedData); + } + + @Test + @DisplayName("캐시에 데이터가 없으면 null을 반환한다") + void getCachedData_NoData_ReturnsNull() { + // when + RealTimeOhlcDto result = service.getCachedData("NONEXISTENT"); + + // then + assertThat(result).isNull(); + } + + // ===== createOhlcDto 테스트 ===== + @Test + @DisplayName("OHLCV 데이터로 DTO를 생성한다") + void createOhlcDto_ValidData_CreatesCorrectDto() { + // given + RealTimeOhlcService.calculateOhlcv ohlcv = + new RealTimeOhlcService.calculateOhlcv(100.0, 200.0, 50.0, 150.0, 10.0); + + // when + RealTimeOhlcDto result = service.createOhlcDto(validTicker, fixedNow, ohlcv); + + // then + assertThat(result.getTicker()).isEqualTo(validTicker); + assertThat(result.getTimestamp()).isEqualTo(fixedNow); + assertThat(result.getOpen()).isEqualTo(100.0); + assertThat(result.getHigh()).isEqualTo(200.0); + assertThat(result.getLow()).isEqualTo(50.0); + assertThat(result.getClose()).isEqualTo(150.0); + assertThat(result.getVolume()).isEqualTo(10.0); + } + + // ===== getCalculateOhlcv 정적 메서드 테스트 ===== + @Test + @DisplayName("단일 거래로 OHLCV를 계산한다") + void getCalculateOhlcv_SingleTrade_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(fixedNow, 100.0, 5.0) + ); + + // when + RealTimeOhlcService.calculateOhlcv result = + RealTimeOhlcService.getCalculateOhlcv(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(100.0); + assertThat(result.low()).isEqualTo(100.0); + assertThat(result.close()).isEqualTo(100.0); + assertThat(result.volume()).isEqualTo(5.0); + } + + @Test + @DisplayName("여러 거래로 OHLCV를 계산한다") + void getCalculateOhlcv_MultipleTrades_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(fixedNow.minusSeconds(3), 100.0, 1.0), // open + createTrade(fixedNow.minusSeconds(2), 200.0, 2.0), // high + createTrade(fixedNow.minusSeconds(1), 50.0, 3.0), // low + createTrade(fixedNow, 150.0, 4.0) // close + ); + + // when + RealTimeOhlcService.calculateOhlcv result = + RealTimeOhlcService.getCalculateOhlcv(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(200.0); + assertThat(result.low()).isEqualTo(50.0); + assertThat(result.close()).isEqualTo(150.0); + assertThat(result.volume()).isEqualTo(10.0); // 1+2+3+4 + } + + @Test + @DisplayName("동일한 가격의 거래들로 OHLCV를 계산한다") + void getCalculateOhlcv_SamePriceTrades_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(fixedNow.minusSeconds(2), 100.0, 1.0), + createTrade(fixedNow.minusSeconds(1), 100.0, 2.0), + createTrade(fixedNow, 100.0, 3.0) + ); + + // when + RealTimeOhlcService.calculateOhlcv result = + RealTimeOhlcService.getCalculateOhlcv(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(100.0); + assertThat(result.low()).isEqualTo(100.0); + assertThat(result.close()).isEqualTo(100.0); + assertThat(result.volume()).isEqualTo(6.0); + } + + @Test + @DisplayName("소수점 가격과 거래량으로 정확하게 계산한다") + void getCalculateOhlcv_DecimalValues_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(fixedNow.minusSeconds(1), 100.5, 1.5), + createTrade(fixedNow, 200.75, 2.25) + ); + + // when + RealTimeOhlcService.calculateOhlcv result = + RealTimeOhlcService.getCalculateOhlcv(trades); + + // then + assertThat(result.open()).isEqualTo(100.5); + assertThat(result.high()).isEqualTo(200.75); + assertThat(result.low()).isEqualTo(100.5); + assertThat(result.close()).isEqualTo(200.75); + assertThat(result.volume()).isEqualTo(3.75); // 1.5 + 2.25 + } + + // ===== 레코드 객체 테스트 ===== + @Test + @DisplayName("TimeRange 레코드가 올바르게 동작한다") + void timeRangeRecord_WorksCorrectly() { + // given + LocalDateTime start = fixedNow.minusSeconds(1); + LocalDateTime end = fixedNow; + + // when + RealTimeOhlcService.TimeRange timeRange = new RealTimeOhlcService.TimeRange(start, end); + + // then + assertThat(timeRange.start()).isEqualTo(start); + assertThat(timeRange.end()).isEqualTo(end); + assertThat(timeRange.toString()).contains(start.toString(), end.toString()); + } + + @Test + @DisplayName("calculateOhlcv 레코드가 올바르게 동작한다") + void calculateOhlcvRecord_WorksCorrectly() { + // given + RealTimeOhlcService.calculateOhlcv ohlcv = + new RealTimeOhlcService.calculateOhlcv(100.0, 200.0, 50.0, 150.0, 10.0); + + // then + assertThat(ohlcv.open()).isEqualTo(100.0); + assertThat(ohlcv.high()).isEqualTo(200.0); + assertThat(ohlcv.low()).isEqualTo(50.0); + assertThat(ohlcv.close()).isEqualTo(150.0); + assertThat(ohlcv.volume()).isEqualTo(10.0); + assertThat(ohlcv.toString()).contains("100.0", "200.0", "50.0", "150.0", "10.0"); + } + + // ===== 동시성 테스트 ===== + @Test + @DisplayName("여러 티커를 동시에 처리해도 캐시가 올바르게 동작한다") + void concurrentTickers_CacheWorksCorrectly() { + // given + String ticker1 = "BTC"; + String ticker2 = "ETH"; + RealTimeOhlcDto data1 = new RealTimeOhlcDto(ticker1, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); + RealTimeOhlcDto data2 = new RealTimeOhlcDto(ticker2, fixedNow, 200.0, 200.0, 200.0, 200.0, 10.0); + + // when + service.updateCache(ticker1, fixedNow, data1); + service.updateCache(ticker2, fixedNow, data2); + + // then + assertThat(service.getCachedData(ticker1)).isEqualTo(data1); + assertThat(service.getCachedData(ticker2)).isEqualTo(data2); + assertThat(service.getCachedData(ticker1)).isNotEqualTo(data2); + } + + // ===== Repository 호출 검증 테스트 ===== + @Test + @DisplayName("Repository가 올바른 파라미터로 호출된다") + void repository_CalledWithCorrectParameters() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + any(String.class), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(mockTrades); + + // when + service.getRealTimeOhlc(validTicker); + + // then + ArgumentCaptor tickerCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + + verify(tradeRepository).findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + tickerCaptor.capture(), startCaptor.capture(), endCaptor.capture()); + + assertThat(tickerCaptor.getValue()).isEqualTo(validTicker); + assertThat(startCaptor.getValue()).isBefore(endCaptor.getValue()); + } + + // ===== 헬퍼 메서드들 ===== + private List createMockTrades() { + return List.of( + createTrade(fixedNow.minusSeconds(3), 100.0, 1.0), + createTrade(fixedNow.minusSeconds(2), 150.0, 2.0), + createTrade(fixedNow.minusSeconds(1), 200.0, 3.0) + ); + } + + private Trade createTrade(LocalDateTime tradeTime, Double price, Double size) { + Trade trade = new Trade(); + try { + setField(trade, "tradeTime", tradeTime); + setField(trade, "price", price); + setField(trade, "size", size); + } catch (Exception e) { + throw new RuntimeException("Trade 객체 생성 실패", e); + } + return trade; + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + java.lang.reflect.Field field = Trade.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java new file mode 100644 index 00000000..e8fb109c --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java @@ -0,0 +1,632 @@ +package com.cleanengine.coin.chart.service; + +import com.cleanengine.coin.chart.dto.RealTimeDataDto; +import com.cleanengine.coin.chart.dto.TradeEventDto; +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("RealTimeTradeService 단위 테스트") +class RealTimeTradeServiceTest { + + private RealTimeTradeService service; + private TradeEventDto testTradeEventDto; + private LocalDateTime testTime; + + @BeforeEach + void setUp() { + service = new RealTimeTradeService(); + testTime = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + testTradeEventDto = new TradeEventDto("TRUMP", 1.5, 50000.0, testTime); + } + + @Test + @DisplayName("extractTradeInfo - 정상적인 거래 정보 추출") + void extractTradeInfo_ValidData_ReturnsCorrectTradeInfo() { + // when + RealTimeTradeService.TradeInfo result = service.extractTradeInfo(testTradeEventDto); + + // then + assertThat(result.ticker()).isEqualTo("TRUMP"); + assertThat(result.price()).isEqualTo(50000.0); + assertThat(result.size()).isEqualTo(1.5); + assertThat(result.timestamp()).isEqualTo(testTime); + } + + @Test + @DisplayName("extractTradeInfo - null 타임스탬프 처리") + void extractTradeInfo_NullTimestamp_HandledCorrectly() { + // given + TradeEventDto tradeWithNullTime = new TradeEventDto("TRUMP", 2.0, 3000.0, null); + + // when + RealTimeTradeService.TradeInfo result = service.extractTradeInfo(tradeWithNullTime); + + // then + assertThat(result.ticker()).isEqualTo("TRUMP"); + assertThat(result.price()).isEqualTo(3000.0); + assertThat(result.size()).isEqualTo(2.0); + assertThat(result.timestamp()).isNull(); + } + + // ===== shouldCalculateChangeRate 메서드 테스트 ===== + @Test + @DisplayName("shouldCalculateChangeRate - 이전 거래가 null인 경우") + void shouldCalculateChangeRate_PreviousTradeNull_ReturnsFalse() { + // when + boolean result = service.shouldCalculateChangeRate(null, testTradeEventDto); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldCalculateChangeRate - 이전 거래 가격이 0인 경우") + void shouldCalculateChangeRate_PreviousPriceZero_ReturnsFalse() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 0.0, testTime); + + // when + boolean result = service.shouldCalculateChangeRate(previousTrade, testTradeEventDto); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldCalculateChangeRate - 이전 거래 가격이 음수인 경우") + void shouldCalculateChangeRate_PreviousPriceNegative_ReturnsFalse() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, -100.0, testTime); + + // when + boolean result = service.shouldCalculateChangeRate(previousTrade, testTradeEventDto); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldCalculateChangeRate - 동일한 객체 참조인 경우") + void shouldCalculateChangeRate_SameObjectReference_ReturnsFalse() { + // when + boolean result = service.shouldCalculateChangeRate(testTradeEventDto, testTradeEventDto); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldCalculateChangeRate - 정상적인 조건인 경우") + void shouldCalculateChangeRate_ValidCondition_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, testTime.minusSeconds(10)); + + // when + boolean result = service.shouldCalculateChangeRate(previousTrade, testTradeEventDto); + + // then + assertThat(result).isTrue(); + } + + // ===== isNewTrade 메서드 테스트 ===== + @Test + @DisplayName("isNewTrade - 이전 타임스탬프가 null인 경우") + void isNewTrade_PreviousTimestampNull_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, null); + + // when + boolean result = service.isNewTrade(previousTrade, testTradeEventDto); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isNewTrade - 현재 타임스탬프가 null인 경우") + void isNewTrade_CurrentTimestampNull_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, testTime); + TradeEventDto currentTrade = new TradeEventDto("TRUMP", 1.5, 50000.0, null); + + // when + boolean result = service.isNewTrade(previousTrade, currentTrade); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isNewTrade - 둘 다 null인 경우") + void isNewTrade_BothTimestampsNull_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, null); + TradeEventDto currentTrade = new TradeEventDto("TRUMP", 1.5, 50000.0, null); + + // when + boolean result = service.isNewTrade(previousTrade, currentTrade); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isNewTrade - 동일한 타임스탬프인 경우") + void isNewTrade_SameTimestamp_ReturnsFalse() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, testTime); + TradeEventDto currentTrade = new TradeEventDto("TRUMP", 1.5, 50000.0, testTime); + + // when + boolean result = service.isNewTrade(previousTrade, currentTrade); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("isNewTrade - 다른 타임스탬프인 경우") + void isNewTrade_DifferentTimestamp_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, testTime.minusSeconds(10)); + + // when + boolean result = service.isNewTrade(previousTrade, testTradeEventDto); + + // then + assertThat(result).isTrue(); + } + + // ===== createCachedTradeDto 메서드 테스트 ===== + @Test + @DisplayName("createCachedTradeDto - 정상적인 복사본 생성") + void createCachedTradeDto_ValidInput_ReturnsCorrectCopy() { + // when + TradeEventDto result = service.createCachedTradeDto(testTradeEventDto); + + // then + assertThat(result.getTicker()).isEqualTo(testTradeEventDto.getTicker()); + assertThat(result.getPrice()).isEqualTo(testTradeEventDto.getPrice()); + assertThat(result.getSize()).isEqualTo(testTradeEventDto.getSize()); + assertThat(result.getTimestamp()).isEqualTo(testTradeEventDto.getTimestamp()); + // 다른 객체임을 확인 + assertThat(result).isNotSameAs(testTradeEventDto); + } + + @Test + @DisplayName("createCachedTradeDto - null 타임스탬프 복사") + void createCachedTradeDto_NullTimestamp_CopiedCorrectly() { + // given + TradeEventDto tradeWithNullTime = new TradeEventDto("TRUMP", 2.0, 3000.0, null); + + // when + TradeEventDto result = service.createCachedTradeDto(tradeWithNullTime); + + // then + assertThat(result.getTimestamp()).isNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + } + + // ===== createRealTimeDataDto 메서드 테스트 ===== + @Test + @DisplayName("createRealTimeDataDto - 정상적인 DTO 생성") + void createRealTimeDataDto_ValidInput_ReturnsCorrectDto() { + // given + RealTimeTradeService.TradeInfo tradeInfo = new RealTimeTradeService.TradeInfo( + "TRUMP", 50000.0, 1.5, testTime); + double changeRate = 5.5; + + // when + RealTimeDataDto result = service.createRealTimeDataDto(tradeInfo, changeRate); + + // then + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getPrice()).isEqualTo(50000.0); + assertThat(result.getSize()).isEqualTo(1.5); + assertThat(result.getChangeRate()).isEqualTo(5.5); + assertThat(result.getTimestamp()).isEqualTo(testTime); + assertThat(result.getTransactionId()).isNotNull(); + } + + @Test + @DisplayName("createRealTimeDataDto - 0 변동률 처리") + void createRealTimeDataDto_ZeroChangeRate_HandledCorrectly() { + // given + RealTimeTradeService.TradeInfo tradeInfo = new RealTimeTradeService.TradeInfo( + "TRUMP", 3000.0, 2.0, testTime); + + // when + RealTimeDataDto result = service.createRealTimeDataDto(tradeInfo, 0.0); + + // then + assertThat(result.getChangeRate()).isEqualTo(0.0); + } + + @Test + @DisplayName("createRealTimeDataDto - 음수 변동률 처리") + void createRealTimeDataDto_NegativeChangeRate_HandledCorrectly() { + // given + RealTimeTradeService.TradeInfo tradeInfo = new RealTimeTradeService.TradeInfo( + "TRUMP", 3000.0, 2.0, testTime); + + // when + RealTimeDataDto result = service.createRealTimeDataDto(tradeInfo, -10.5); + + // then + assertThat(result.getChangeRate()).isEqualTo(-10.5); + } + + // ===== generateTransactionId 메서드 테스트 ===== + @Test + @DisplayName("generateTransactionId - 유효한 UUID 생성") + void generateTransactionId_ReturnsValidUUID() { + // when + String result = service.generateTransactionId(); + + // then + assertThat(result).isNotNull(); + assertThat(result).isNotEmpty(); + // UUID 형식 검증 + assertThat((AssertProvider) () -> UUID.fromString(result)).getMostSignificantBits(); + } + + @Test + @DisplayName("generateTransactionId - 호출할 때마다 다른 ID 생성") + void generateTransactionId_GeneratesDifferentIds() { + // when + String id1 = service.generateTransactionId(); + String id2 = service.generateTransactionId(); + + // then + assertThat(id1).isNotEqualTo(id2); + } + + @Test + @DisplayName("generateTransactionId - 연속 호출 시 모두 다른 ID") + void generateTransactionId_MultipleCallsGenerateDifferentIds() { + // when + String id1 = service.generateTransactionId(); + String id2 = service.generateTransactionId(); + String id3 = service.generateTransactionId(); + + // then + assertThat(id1).isNotEqualTo(id2); + assertThat(id2).isNotEqualTo(id3); + assertThat(id1).isNotEqualTo(id3); + } + + // ===== getChangeRate 메서드 테스트 ===== + @Test + @DisplayName("getChangeRate - 가격 상승 케이스") + void getChangeRate_PriceIncrease_CalculatesCorrectly() { + // when + double result = service.getChangeRate(110.0, 100.0); + + // then + assertThat(result).isEqualTo(10.0); + } + + @Test + @DisplayName("getChangeRate - 가격 하락 케이스") + void getChangeRate_PriceDecrease_CalculatesCorrectly() { + // when + double result = service.getChangeRate(90.0, 100.0); + + // then + assertThat(result).isEqualTo(-10.0); + } + + @Test + @DisplayName("getChangeRate - 가격 변동 없음") + void getChangeRate_NoChange_ReturnsZero() { + // when + double result = service.getChangeRate(100.0, 100.0); + + // then + assertThat(result).isEqualTo(0.0); + } + + @Test + @DisplayName("getChangeRate - 소수점 가격 처리") + void getChangeRate_DecimalPrices_CalculatesCorrectly() { + // when + double result = service.getChangeRate(105.50, 100.25); + + // then + double expected = ((105.50 - 100.25) / 100.25) * 100; + assertThat(result).isCloseTo(expected, org.assertj.core.data.Offset.offset(0.0001)); + } + + @Test + @DisplayName("getChangeRate - 큰 변동률 처리") + void getChangeRate_LargeChangeRate_CalculatesCorrectly() { + // when + double result = service.getChangeRate(200.0, 100.0); + + // then + assertThat(result).isEqualTo(100.0); + } + + @Test + @DisplayName("getChangeRate - 매우 작은 가격 변동") + void getChangeRate_VerySmallChange_CalculatesCorrectly() { + // when + double result = service.getChangeRate(100.01, 100.0); + + // then + assertThat(result).isCloseTo(0.01, org.assertj.core.data.Offset.offset(0.0001)); + } + + // ===== Record 클래스 테스트 ===== + @Test + @DisplayName("TradeInfo record - 정상 동작 확인") + void tradeInfo_Record_WorksCorrectly() { + // given + RealTimeTradeService.TradeInfo tradeInfo = new RealTimeTradeService.TradeInfo( + "TRUMP", 50000.0, 1.5, testTime); + + // then + assertThat(tradeInfo.ticker()).isEqualTo("TRUMP"); + assertThat(tradeInfo.price()).isEqualTo(50000.0); + assertThat(tradeInfo.size()).isEqualTo(1.5); + assertThat(tradeInfo.timestamp()).isEqualTo(testTime); + } + + @Test + @DisplayName("TradeInfo record - equals와 hashCode 동작") + void tradeInfo_Record_EqualsAndHashCode() { + // given + RealTimeTradeService.TradeInfo tradeInfo1 = new RealTimeTradeService.TradeInfo( + "TRUMP", 50000.0, 1.5, testTime); + RealTimeTradeService.TradeInfo tradeInfo2 = new RealTimeTradeService.TradeInfo( + "TRUMP", 50000.0, 1.5, testTime); + + // then + assertThat(tradeInfo1).isEqualTo(tradeInfo2); + assertThat(tradeInfo1.hashCode()).isEqualTo(tradeInfo2.hashCode()); + } + + @Test + @DisplayName("ChangeRateResult record - 정상 동작 확인") + void changeRateResult_Record_WorksCorrectly() { + // given + RealTimeTradeService.ChangeRateResult result = new RealTimeTradeService.ChangeRateResult(5.5, true); + + // then + assertThat(result.changeRate()).isEqualTo(5.5); + assertThat(result.shouldUpdate()).isTrue(); + } + + @Test + @DisplayName("ChangeRateResult record - false 케이스") + void changeRateResult_Record_FalseCase() { + // given + RealTimeTradeService.ChangeRateResult result = new RealTimeTradeService.ChangeRateResult(0.0, false); + + // then + assertThat(result.changeRate()).isEqualTo(0.0); + assertThat(result.shouldUpdate()).isFalse(); + } + + @Test + @DisplayName("ChangeRateResult record - equals와 hashCode 동작") + void changeRateResult_Record_EqualsAndHashCode() { + // given + RealTimeTradeService.ChangeRateResult result1 = new RealTimeTradeService.ChangeRateResult(5.5, true); + RealTimeTradeService.ChangeRateResult result2 = new RealTimeTradeService.ChangeRateResult(5.5, true); + + // then + assertThat(result1).isEqualTo(result2); + assertThat(result1.hashCode()).isEqualTo(result2.hashCode()); + } + + + // ===== updateTradeCache 메서드 테스트 ===== + @Test + @DisplayName("updateTradeCache - shouldUpdate가 true일 때 캐시 업데이트") + void updateTradeCache_ShouldUpdateTrue_UpdatesCache() { + // given + RealTimeTradeService.ChangeRateResult changeRateResult = + new RealTimeTradeService.ChangeRateResult(5.0, true); + + // when + service.updateTradeCache(testTradeEventDto, changeRateResult); + + // then - 내부 상태 확인을 위해 다음 호출에서 이전 데이터로 사용되는지 확인 + TradeEventDto newTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime.plusSeconds(10)); + RealTimeTradeService.TradeInfo newTradeInfo = service.extractTradeInfo(newTrade); + + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(newTrade, newTradeInfo); + assertThat(result.shouldUpdate()).isTrue(); // 이전 데이터가 캐시되었으므로 계산 가능 + } + + @Test + @DisplayName("updateTradeCache - shouldUpdate가 false지만 캐시에 없을 때 업데이트") + void updateTradeCache_ShouldUpdateFalseButNoCachedData_UpdatesCache() { + // given + RealTimeTradeService.ChangeRateResult changeRateResult = + new RealTimeTradeService.ChangeRateResult(0.0, false); + + // when + service.updateTradeCache(testTradeEventDto, changeRateResult); + + // then - 캐시되었는지 확인 + TradeEventDto newTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime.plusSeconds(10)); + RealTimeTradeService.TradeInfo newTradeInfo = service.extractTradeInfo(newTrade); + + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(newTrade, newTradeInfo); + assertThat(result.shouldUpdate()).isTrue(); + } + + @Test + @DisplayName("updateTradeCache - shouldUpdate가 false이고 캐시에 있을 때 업데이트 안함") + void updateTradeCache_ShouldUpdateFalseAndCachedDataExists_DoesNotUpdate() { + // given - 먼저 캐시에 데이터 추가 + RealTimeTradeService.ChangeRateResult firstResult = + new RealTimeTradeService.ChangeRateResult(5.0, true); + service.updateTradeCache(testTradeEventDto, firstResult); + + // 다른 가격의 거래 데이터 + TradeEventDto differentTrade = new TradeEventDto("TRUMP", 2.0, 60000.0, testTime.plusSeconds(5)); + RealTimeTradeService.ChangeRateResult secondResult = + new RealTimeTradeService.ChangeRateResult(0.0, false); + + // when + service.updateTradeCache(differentTrade, secondResult); + + // then - 원래 캐시된 데이터가 유지되는지 확인 + TradeEventDto thirdTrade = new TradeEventDto("TRUMP", 1.0, 55000.0, testTime.plusSeconds(10)); + RealTimeTradeService.TradeInfo thirdTradeInfo = service.extractTradeInfo(thirdTrade); + + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(thirdTrade, thirdTradeInfo); + // 첫 번째 캐시된 데이터(50000.0)와 비교되어야 함 + double expectedChangeRate = ((55000.0 - 50000.0) / 50000.0) * 100; + assertThat(result.changeRate()).isCloseTo(expectedChangeRate, org.assertj.core.data.Offset.offset(0.01)); + } + + // ===== generateRealTimeData 메서드 테스트 ===== + @Test + @DisplayName("generateRealTimeData - 정상적인 실시간 데이터 생성") + void generateRealTimeData_ValidInput_ReturnsCorrectData() { + // when + RealTimeDataDto result = service.generateRealTimeData(testTradeEventDto); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getPrice()).isEqualTo(50000.0); + assertThat(result.getSize()).isEqualTo(1.5); + assertThat(result.getTimestamp()).isEqualTo(testTime); + assertThat(result.getTransactionId()).isNotNull(); + assertThat(result.getChangeRate()).isEqualTo(0.0); // 첫 번째 거래이므로 0 + } + + @Test + @DisplayName("generateRealTimeData - 두 번째 거래에서 변동률 계산") + void generateRealTimeData_SecondTrade_CalculatesChangeRate() { + // given - 첫 번째 거래 + service.generateRealTimeData(testTradeEventDto); + + // 두 번째 거래 (가격 상승) + TradeEventDto secondTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime.plusSeconds(10)); + + // when + RealTimeDataDto result = service.generateRealTimeData(secondTrade); + + // then + assertThat(result.getChangeRate()).isEqualTo(10.0); // (55000-50000)/50000 * 100 + } + + @Test + @DisplayName("generateRealTimeData - 동일한 타임스탬프 거래 처리") + void generateRealTimeData_SameTimestamp_ReturnsZeroChangeRate() { + // given - 첫 번째 거래 + service.generateRealTimeData(testTradeEventDto); + + // 동일한 타임스탬프의 다른 거래 + TradeEventDto sameTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime); + + // when + RealTimeDataDto result = service.generateRealTimeData(sameTrade); + + // then + assertThat(result.getChangeRate()).isEqualTo(0.0); + } + + @Test + @DisplayName("generateRealTimeData - 예외 발생 시 기본값 반환") + void generateRealTimeData_ExceptionOccurs_ReturnsDefaultData() { + // given - null 값으로 예외 유발 가능한 데이터 + TradeEventDto nullTrade = new TradeEventDto(null, 1.0, 50000.0, testTime); + + // when + RealTimeDataDto result = service.generateRealTimeData(nullTrade); + + // then + assertThat(result).isNotNull(); + assertThat(result.getChangeRate()).isEqualTo(0.0); + } + + // ===== calculateChangeRate 메서드 테스트 ===== + @Test + @DisplayName("calculateChangeRate - 이전 거래가 없는 경우") + void calculateChangeRate_NoPreviousTrade_ReturnsZeroWithFalse() { + // given + RealTimeTradeService.TradeInfo tradeInfo = service.extractTradeInfo(testTradeEventDto); + + // when + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(testTradeEventDto, tradeInfo); + + // then + assertThat(result.changeRate()).isEqualTo(0.0); + assertThat(result.shouldUpdate()).isFalse(); + } + + @Test + @DisplayName("calculateChangeRate - 정상적인 변동률 계산") + void calculateChangeRate_ValidPreviousTrade_CalculatesCorrectly() { + // given - 이전 거래 캐시에 저장 + RealTimeTradeService.ChangeRateResult firstResult = + new RealTimeTradeService.ChangeRateResult(0.0, true); + service.updateTradeCache(testTradeEventDto, firstResult); + + // 새로운 거래 + TradeEventDto newTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime.plusSeconds(10)); + RealTimeTradeService.TradeInfo newTradeInfo = service.extractTradeInfo(newTrade); + + // when + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(newTrade, newTradeInfo); + + // then + assertThat(result.changeRate()).isEqualTo(10.0); // (55000-50000)/50000 * 100 + assertThat(result.shouldUpdate()).isTrue(); + } + + @Test + @DisplayName("calculateChangeRate - 동일한 타임스탬프 거래") + void calculateChangeRate_SameTimestamp_ReturnsZeroWithFalse() { + // given - 이전 거래 캐시에 저장 + RealTimeTradeService.ChangeRateResult firstResult = + new RealTimeTradeService.ChangeRateResult(0.0, true); + service.updateTradeCache(testTradeEventDto, firstResult); + + // 동일한 타임스탬프의 거래 + TradeEventDto sameTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime); + RealTimeTradeService.TradeInfo sameTradeInfo = service.extractTradeInfo(sameTrade); + + // when + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(sameTrade, sameTradeInfo); + + // then + assertThat(result.changeRate()).isEqualTo(0.0); + assertThat(result.shouldUpdate()).isFalse(); + } + + @Test + @DisplayName("calculateChangeRate - 이전 거래 가격이 0인 경우") + void calculateChangeRate_PreviousPriceZero_ReturnsZeroWithFalse() { + // given - 가격이 0인 이전 거래 캐시에 저장 + TradeEventDto zeroPriceTrade = new TradeEventDto("TRUMP", 1.0, 0.0, testTime.minusSeconds(10)); + RealTimeTradeService.ChangeRateResult firstResult = + new RealTimeTradeService.ChangeRateResult(0.0, true); + service.updateTradeCache(zeroPriceTrade, firstResult); + + // 새로운 거래 + TradeEventDto newTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime); + RealTimeTradeService.TradeInfo newTradeInfo = service.extractTradeInfo(newTrade); + + // when + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(newTrade, newTradeInfo); + + // then + assertThat(result.changeRate()).isEqualTo(0.0); + assertThat(result.shouldUpdate()).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/WebsocketSendServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/WebsocketSendServiceTest.java new file mode 100644 index 00000000..4c1b456d --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/WebsocketSendServiceTest.java @@ -0,0 +1,333 @@ +package com.cleanengine.coin.chart.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WebsocketSendService 단위 테스트") +class WebsocketSendServiceTest { + + @Mock + private SimpMessagingTemplate messagingTemplate; + + @InjectMocks + private WebsocketSendService websocketSendService; + + private Object testData; + private String testTicker; + + @BeforeEach + void setUp() { + testData = new TestDto("BTC", 50000.0, 1.5); + testTicker = "BTC"; + } + + // ===== buildTopic 메서드 테스트 ===== + @Test + @DisplayName("buildTopic - 정상적인 토픽 생성 (realTimeTradeRate)") + void buildTopic_RealTimeTradeRate_ReturnsCorrectTopic() { + // when + String result = websocketSendService.buildTopic("realTimeTradeRate", "BTC"); + + // then + assertThat(result).isEqualTo("/topic/realTimeTradeRate/BTC"); + } + + @Test + @DisplayName("buildTopic - 정상적인 토픽 생성 (prevRate)") + void buildTopic_PrevRate_ReturnsCorrectTopic() { + // when + String result = websocketSendService.buildTopic("prevRate", "ETH"); + + // then + assertThat(result).isEqualTo("/topic/prevRate/ETH"); + } + + @Test + @DisplayName("buildTopic - 다양한 티커로 정상 생성") + void buildTopic_DifferentTickers_ReturnsCorrectTopic() { + // when & then + assertThat(websocketSendService.buildTopic("realTimeTradeRate", "TRUMP")) + .isEqualTo("/topic/realTimeTradeRate/TRUMP"); + assertThat(websocketSendService.buildTopic("prevRate", "BTC-USD")) + .isEqualTo("/topic/prevRate/BTC-USD"); + } + + @Test + @DisplayName("buildTopic - null 티커인 경우 예외 발생") + void buildTopic_NullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.buildTopic("realTimeTradeRate", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + @Test + @DisplayName("buildTopic - 빈 문자열 티커인 경우 예외 발생") + void buildTopic_EmptyTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.buildTopic("realTimeTradeRate", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + @Test + @DisplayName("buildTopic - 공백만 있는 티커인 경우 예외 발생") + void buildTopic_WhitespaceTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.buildTopic("realTimeTradeRate", " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + @Test + @DisplayName("buildTopic - 탭과 공백이 섞인 티커인 경우 예외 발생") + void buildTopic_TabAndWhitespaceTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.buildTopic("realTimeTradeRate", "\t \n")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + // ===== sendMessage 메서드 테스트 ===== + @Test + @DisplayName("sendMessage - 정상적인 메시지 전송") + void sendMessage_ValidInput_SendsMessageCorrectly() { + // given + String topic = "/topic/realTimeTradeRate/BTC"; + + // when + websocketSendService.sendMessage(topic, testData); + + // then + verify(messagingTemplate, times(1)).convertAndSend(topic, testData); + } + + @Test + @DisplayName("sendMessage - 다른 토픽으로 정상 전송") + void sendMessage_DifferentTopic_SendsCorrectly() { + // given + String topic = "/topic/prevRate/ETH"; + Object data = "test data"; + + // when + websocketSendService.sendMessage(topic, data); + + // then + verify(messagingTemplate, times(1)).convertAndSend(topic, data); + } + + @Test + @DisplayName("sendMessage - null 토픽인 경우 예외 발생") + void sendMessage_NullTopic_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendMessage(null, testData)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("토픽은 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendMessage - 빈 토픽인 경우 예외 발생") + void sendMessage_EmptyTopic_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendMessage("", testData)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("토픽은 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendMessage - 공백만 있는 토픽인 경우 예외 발생") + void sendMessage_WhitespaceTopic_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendMessage(" ", testData)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("토픽은 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendMessage - null 데이터인 경우 예외 발생") + void sendMessage_NullData_ThrowsException() { + // given + String topic = "/topic/realTimeTradeRate/BTC"; + + // when & then + assertThatThrownBy(() -> websocketSendService.sendMessage(topic, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("데이터는 null일 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + // ===== sendChangeRate 메서드 테스트 ===== + @Test + @DisplayName("sendChangeRate - 정상적인 실시간 거래 데이터 전송") + void sendChangeRate_ValidInput_SendsCorrectly() { + // when + websocketSendService.sendChangeRate(testData, testTicker); + + // then + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(Object.class); + + verify(messagingTemplate, times(1)).convertAndSend(topicCaptor.capture(), dataCaptor.capture()); + + assertThat(topicCaptor.getValue()).isEqualTo("/topic/realTimeTradeRate/BTC"); + assertThat(dataCaptor.getValue()).isEqualTo(testData); + } + + @Test + @DisplayName("sendChangeRate - 다양한 티커로 정상 전송") + void sendChangeRate_DifferentTickers_SendsCorrectly() { + // given + Object ethData = new TestDto("ETH", 3000.0, 2.0); + + // when + websocketSendService.sendChangeRate(ethData, "ETH"); + + // then + verify(messagingTemplate).convertAndSend("/topic/realTimeTradeRate/ETH", ethData); + } + + @Test + @DisplayName("sendChangeRate - null 티커인 경우 예외 발생") + void sendChangeRate_NullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendChangeRate(testData, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendChangeRate - 빈 티커인 경우 예외 발생") + void sendChangeRate_EmptyTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendChangeRate(testData, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendChangeRate - null 데이터인 경우 예외 발생") + void sendChangeRate_NullData_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendChangeRate(null, testTicker)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("데이터는 null일 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + // ===== sendPrevRate 메서드 테스트 ===== + @Test + @DisplayName("sendPrevRate - 정상적인 전일 대비 데이터 전송") + void sendPrevRate_ValidInput_SendsCorrectly() { + // when + websocketSendService.sendPrevRate(testData, testTicker); + + // then + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(Object.class); + + verify(messagingTemplate, times(1)).convertAndSend(topicCaptor.capture(), dataCaptor.capture()); + + // prevRate 토픽으로 전송됨 + assertThat(topicCaptor.getValue()).isEqualTo("/topic/prevRate/BTC"); + assertThat(dataCaptor.getValue()).isEqualTo(testData); + } + + @Test + @DisplayName("sendPrevRate - 다양한 티커로 정상 전송") + void sendPrevRate_DifferentTickers_SendsCorrectly() { + // given + Object trumpData = new TestDto("TRUMP", 150.0, 2.5); + + // when + websocketSendService.sendPrevRate(trumpData, "TRUMP"); + + // then + verify(messagingTemplate).convertAndSend("/topic/prevRate/TRUMP", trumpData); + } + + @Test + @DisplayName("sendPrevRate - null 티커인 경우 예외 발생") + void sendPrevRate_NullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendPrevRate(testData, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendPrevRate - 공백 티커인 경우 예외 발생") + void sendPrevRate_WhitespaceTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendPrevRate(testData, " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendPrevRate - null 데이터인 경우 예외 발생") + void sendPrevRate_NullData_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendPrevRate(null, testTicker)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("데이터는 null일 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + + // 테스트용 DTO 클래스 + private static class TestDto { + private final String ticker; + private final Double price; + private final Double size; + + public TestDto(String ticker, Double price, Double size) { + this.ticker = ticker; + this.price = price; + this.size = size; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + TestDto testDto = (TestDto) obj; + return ticker.equals(testDto.ticker) && + price.equals(testDto.price) && + size.equals(testDto.size); + } + + @Override + public String toString() { + return String.format("TestDto{ticker='%s', price=%s, size=%s}", ticker, price, size); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java b/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java new file mode 100644 index 00000000..93335a3e --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java @@ -0,0 +1,333 @@ +package com.cleanengine.coin.chart.service.minute; + +import com.cleanengine.coin.chart.dto.RealTimeOhlcDto; +import com.cleanengine.coin.chart.repository.MinuteOhlcDataRepository; +import com.cleanengine.coin.trade.entity.Trade; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MinuteOhlcDataServiceImpl 단위 테스트") +class MinuteOhlcDataServiceImplTest { + + @Mock + private MinuteOhlcDataRepository tradeRepository; + + @InjectMocks + private MinuteOhlcDataServiceImpl service; + + private List mockTrades; + private String validTicker; + + @BeforeEach + void setUp() { + validTicker = "BTC"; + mockTrades = createMockTrades(); + } + + // ===== getMinuteOhlcData 테스트 ===== + @Test + @DisplayName("정상적인 티커로 분봉 데이터를 조회한다") + void getMinuteOhlcData_ValidTicker_ReturnsOhlcData() { + // given + when(tradeRepository.findByTickerOrderByTradeTimeAsc(validTicker)) + .thenReturn(mockTrades); + + // when + List result = service.getMinuteOhlcData(validTicker); + + // then + assertThat(result).isNotEmpty(); + assertThat(result).hasSize(2); // 2분간의 데이터 + + RealTimeOhlcDto firstMinute = result.getFirst(); + assertThat(firstMinute.getTicker()).isEqualTo("BTC"); + assertThat(firstMinute.getOpen()).isEqualTo(100.0); + assertThat(firstMinute.getHigh()).isEqualTo(150.0); + assertThat(firstMinute.getLow()).isEqualTo(100.0); + assertThat(firstMinute.getClose()).isEqualTo(150.0); + assertThat(firstMinute.getVolume()).isEqualTo(3.0); // 1.0 + 2.0 + } + + @Test + @DisplayName("거래 데이터가 없으면 빈 리스트를 반환한다") + void getMinuteOhlcData_NoTrades_ReturnsEmptyList() { + // given + when(tradeRepository.findByTickerOrderByTradeTimeAsc(validTicker)) + .thenReturn(List.of()); + + // when + List result = service.getMinuteOhlcData(validTicker); + + // then + assertThat(result).isEmpty(); + } + + // ===== validateTicker 테스트 ===== + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + @DisplayName("잘못된 티커로 검증하면 예외가 발생한다") + void validateTicker_InvalidTicker_ThrowsException(String invalidTicker) { + // when & then + assertThatThrownBy(() -> service.validateTicker(invalidTicker)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + @Test + @DisplayName("유효한 티커는 검증을 통과한다") + void validateTicker_ValidTicker_Success() { + // when & then (예외가 발생하지 않아야 함) + service.validateTicker("BTC"); + service.validateTicker("ETH-USD"); + service.validateTicker("123"); + } + + // ===== groupTradesByMinute 테스트 ===== + @Test + @DisplayName("거래 데이터를 분 단위로 그룹핑한다") + void groupTradesByMinute_ValidTrades_GroupsCorrectly() { + // when + Map> result = service.groupTradesByMinute(mockTrades); + + // then + assertThat(result).hasSize(2); + + LocalDateTime firstMinute = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + LocalDateTime secondMinute = LocalDateTime.of(2024, 1, 15, 10, 31, 0); + + assertThat(result).containsKey(firstMinute); + assertThat(result).containsKey(secondMinute); + assertThat(result.get(firstMinute)).hasSize(2); + assertThat(result.get(secondMinute)).hasSize(1); + } + + @Test + @DisplayName("빈 거래 리스트는 빈 맵을 반환한다") + void groupTradesByMinute_EmptyTrades_ReturnsEmptyMap() { + // when + Map> result = service.groupTradesByMinute(List.of()); + + // then + assertThat(result).isEmpty(); + } + + // ===== truncateToMinute 테스트 ===== + @Test + @DisplayName("거래 시간을 분 단위로 자른다") + void truncateToMinute_ValidTrade_TruncatesCorrectly() { + // given + Trade trade = createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 45), 100.0, 1.0); + + // when + LocalDateTime result = service.truncateToMinute(trade); + + // then + assertThat(result).isEqualTo(LocalDateTime.of(2024, 1, 15, 10, 30, 0)); + } + + // ===== createOhlcDto 테스트 ===== + @Test + @DisplayName("단일 분 거래 데이터로 OHLC DTO를 생성한다") + void createOhlcDto_ValidTrades_CreatesCorrectDto() { + // given + String ticker = "BTC"; + LocalDateTime minute = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + List trades = List.of( + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 10), 100.0, 1.0), + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 30), 150.0, 2.0) + ); + + // when + RealTimeOhlcDto result = service.createOhlcDto(ticker, minute, trades); + + // then + assertThat(result.getTicker()).isEqualTo("BTC"); + assertThat(result.getTimestamp()).isEqualTo(minute); + assertThat(result.getOpen()).isEqualTo(100.0); + assertThat(result.getHigh()).isEqualTo(150.0); + assertThat(result.getLow()).isEqualTo(100.0); + assertThat(result.getClose()).isEqualTo(150.0); + assertThat(result.getVolume()).isEqualTo(3.0); + } + + // ===== validateTradeList 테스트 ===== + @Test + @DisplayName("null 거래 리스트는 예외를 발생시킨다") + void validateTradeList_NullTrades_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.validateTradeList(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("거래 데이터가 없습니다"); + } + + @Test + @DisplayName("빈 거래 리스트는 예외를 발생시킨다") + void validateTradeList_EmptyTrades_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.validateTradeList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("거래 데이터가 없습니다"); + } + + @Test + @DisplayName("유효한 거래 리스트는 검증을 통과한다") + void validateTradeList_ValidTrades_Success() { + // when & then (예외가 발생하지 않아야 함) + service.validateTradeList(mockTrades); + } + + // ===== calculateOhlcData 정적 메서드 테스트 ===== + @Test + @DisplayName("단일 거래 데이터로 OHLC를 계산한다") + void calculateOhlcData_SingleTrade_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(LocalDateTime.now(), 100.0, 5.0) + ); + + // when + MinuteOhlcDataServiceImpl.OhlcData result = + MinuteOhlcDataServiceImpl.calculateOhlcData(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(100.0); + assertThat(result.low()).isEqualTo(100.0); + assertThat(result.close()).isEqualTo(100.0); + assertThat(result.volume()).isEqualTo(5.0); + } + + @Test + @DisplayName("여러 거래 데이터로 OHLC를 계산한다") + void calculateOhlcData_MultipleTrades_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 10), 100.0, 1.0), // open + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 20), 200.0, 2.0), // high + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 30), 50.0, 3.0), // low + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 40), 150.0, 4.0) // close + ); + + // when + MinuteOhlcDataServiceImpl.OhlcData result = + MinuteOhlcDataServiceImpl.calculateOhlcData(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(200.0); + assertThat(result.low()).isEqualTo(50.0); + assertThat(result.close()).isEqualTo(150.0); + assertThat(result.volume()).isEqualTo(10.0); // 1+2+3+4 + } + + @Test + @DisplayName("동일한 가격의 거래들로 OHLC를 계산한다") + void calculateOhlcData_SamePriceTrades_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(LocalDateTime.now(), 100.0, 1.0), + createTrade(LocalDateTime.now(), 100.0, 2.0), + createTrade(LocalDateTime.now(), 100.0, 3.0) + ); + + // when + MinuteOhlcDataServiceImpl.OhlcData result = + MinuteOhlcDataServiceImpl.calculateOhlcData(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(100.0); + assertThat(result.low()).isEqualTo(100.0); + assertThat(result.close()).isEqualTo(100.0); + assertThat(result.volume()).isEqualTo(6.0); + } + + // ===== OhlcData 레코드 테스트 ===== + @Test + @DisplayName("OhlcData 레코드가 올바르게 동작한다") + void ohlcDataRecord_WorksCorrectly() { + // given + MinuteOhlcDataServiceImpl.OhlcData ohlcData = + new MinuteOhlcDataServiceImpl.OhlcData(100.0, 200.0, 50.0, 150.0, 10.0); + + // then + assertThat(ohlcData.open()).isEqualTo(100.0); + assertThat(ohlcData.high()).isEqualTo(200.0); + assertThat(ohlcData.low()).isEqualTo(50.0); + assertThat(ohlcData.close()).isEqualTo(150.0); + assertThat(ohlcData.volume()).isEqualTo(10.0); + assertThat(ohlcData.toString()).contains("100.0", "200.0", "50.0", "150.0", "10.0"); + } + + // ===== 경계값 테스트 ===== + @Test + @DisplayName("소수점 가격과 거래량으로 정확하게 계산한다") + void calculateOhlcData_DecimalValues_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(LocalDateTime.now(), 100.5, 1.5), + createTrade(LocalDateTime.now(), 200.75, 2.25) + ); + + // when + MinuteOhlcDataServiceImpl.OhlcData result = + MinuteOhlcDataServiceImpl.calculateOhlcData(trades); + + // then + assertThat(result.open()).isEqualTo(100.5); + assertThat(result.high()).isEqualTo(200.75); + assertThat(result.low()).isEqualTo(100.5); + assertThat(result.close()).isEqualTo(200.75); + assertThat(result.volume()).isEqualTo(3.75); // 1.5 + 2.25 + } + + // =====리플렉션 제거===== + private List createMockTrades() { + return List.of( + // 첫 번째 분 (10:30) + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 10), 100.0, 1.0), + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 30), 150.0, 2.0), + + // 두 번째 분 (10:31) + createTrade(LocalDateTime.of(2024, 1, 15, 10, 31, 20), 200.0, 3.0) + ); + } + + /** + * Trade 엔티티 생성 - @AllArgsConstructor 사용하여 리플렉션 제거 + * + * @param tradeTime 거래 시간 + * @param price 가격 + * @param size 거래량 + * @return Trade 객체 + */ + private Trade createTrade(LocalDateTime tradeTime, Double price, Double size) { + return new Trade( + null, // id (자동 생성) 실제 db에 들어가는게 아니기때문에 null로 설정 + "BTC", // ticker + tradeTime, // tradeTime + 1, // buyUserId (더미 값) + 2, // sellUserId (더미 값) + price, // price + size // size + ); + } +} \ No newline at end of file