From d969b9427a5cde81bd1279cf720a0fdf8f5e266c Mon Sep 17 00:00:00 2001 From: bongj9 Date: Sun, 1 Jun 2025 16:09:53 +0900 Subject: [PATCH 01/17] =?UTF-8?q?refactor:=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EC=A2=8B=EA=B2=8C=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81(SRP=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RealTimeDataPrevRateService.java | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) 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..880ceb51 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; @@ -13,34 +14,52 @@ @Service @RequiredArgsConstructor @Slf4j +//todo: 테스트 하기 쉽게 변환하기(메서드 분리) 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); String ticker = currentTrade.getTicker(); + LocalDateTime today = LocalDateTime.now(); + YesterDay yesterDay = getYesterDay(today); + 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()); } 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() + today ); } + + private static double getChangeRate(double currentPrice, double prevClose) { + double changeRate = ((currentPrice - prevClose) / prevClose) * 100; + return changeRate; + } + + //시간 데이터는 파라미터로 주입받아서 활용받는게 좋음(test관점) + @NotNull + private 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); + YesterDay result = new YesterDay(yesterdayStart, yesterdayEnd); + return result; + } + + private record YesterDay(LocalDateTime yesterdayStart, LocalDateTime yesterdayEnd) { + } } \ No newline at end of file From c81e2d5c02e42b0b3a1a4f9e337e1eb27e0b29c6 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Sun, 1 Jun 2025 16:40:58 +0900 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=EC=8B=9C=EA=B0=84=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=A5=BC=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EB=A1=9C=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(=ED=85=8C=EC=8A=A4=ED=8A=B8=ED=95=A0=EB=95=8C?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=8B=9C=EA=B0=84=EC=9D=B4=20=EB=8B=AC?= =?UTF-8?q?=EB=9D=BC=EC=A7=80=EB=8A=94=EC=A0=90=EB=95=8C=EB=AC=B8=EC=97=90?= =?UTF-8?q?=20=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RealTimeDataPrevRateService.java | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) 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 880ceb51..b4e7f17f 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java @@ -14,16 +14,17 @@ @Service @RequiredArgsConstructor @Slf4j -//todo: 테스트 하기 쉽게 변환하기(메서드 분리) public class RealTimeDataPrevRateService { private final RealTimeTradeRepository tradeRepository; public PrevRateDto generatePrevRateData(TradeEventDto currentTrade) { - // 전일 종가 계산 + return generatePrevRateData(currentTrade, LocalDateTime.now()); + } + + PrevRateDto generatePrevRateData(TradeEventDto currentTrade, LocalDateTime currentTime) { String ticker = currentTrade.getTicker(); - LocalDateTime today = LocalDateTime.now(); - YesterDay yesterDay = getYesterDay(today); + YesterDay yesterDay = getYesterDay(currentTime); log.debug("조회 시간 범위: {} ~ {}", yesterDay.yesterdayStart(), yesterDay.yesterdayEnd()); Trade yesterdayLastTrade = tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( @@ -31,7 +32,7 @@ public PrevRateDto generatePrevRateData(TradeEventDto currentTrade) { 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(); @@ -42,24 +43,21 @@ public PrevRateDto generatePrevRateData(TradeEventDto currentTrade) { prevClose, currentPrice, changeRate, - today + currentTime ); } - private static double getChangeRate(double currentPrice, double prevClose) { - double changeRate = ((currentPrice - prevClose) / prevClose) * 100; - return changeRate; + static double getChangeRate(double currentPrice, double prevClose) { + return ((currentPrice - prevClose) / prevClose) * 100; } - //시간 데이터는 파라미터로 주입받아서 활용받는게 좋음(test관점) @NotNull - private static YesterDay getYesterDay(LocalDateTime today) { + 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); - YesterDay result = new YesterDay(yesterdayStart, yesterdayEnd); - return result; + return new YesterDay(yesterdayStart, yesterdayEnd); } - private record YesterDay(LocalDateTime yesterdayStart, LocalDateTime yesterdayEnd) { + record YesterDay(LocalDateTime yesterdayStart, LocalDateTime yesterdayEnd) { } } \ No newline at end of file From 6888f2fa3041c3a9559dd8170b287d37354cc620 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 00:31:50 +0900 Subject: [PATCH 03/17] =?UTF-8?q?refactor:=EA=B2=B0=ED=95=A9=EB=8F=84?= =?UTF-8?q?=EA=B0=80=20=EB=86=92=EC=9D=80=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=93=A4=EC=9D=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=A2=8B=EA=B2=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC,SRP=20=EC=9B=90=EC=B9=99=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chart/service/RealTimeOhlcService.java | 111 ++++++++++++------ 1 file changed, 72 insertions(+), 39 deletions(-) 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..4eb6f946 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java @@ -5,6 +5,7 @@ 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; @@ -31,56 +32,88 @@ 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) { + List recentTrades = tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + ticker, + timeRange.start(), + timeRange.end() + ); + + Collections.reverse(recentTrades); + return recentTrades; + } + + // 캐시 업데이트 + 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 recentTrades) { + 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(); + 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 From 9c1ae72be059e1e648b85c72cc20d7813ce24f68 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 00:32:00 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor:=EA=B2=B0=ED=95=A9=EB=8F=84?= =?UTF-8?q?=EA=B0=80=20=EB=86=92=EC=9D=80=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=93=A4=EC=9D=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=A2=8B=EA=B2=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC,SRP=20=EC=9B=90=EC=B9=99=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minute/MinuteOhlcDataServiceImpl.java | 126 ++++++++++++------ 1 file changed, 88 insertions(+), 38 deletions(-) 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 From c8c7ad75b2e20438fc6abd34242d89ec70f15aa0 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 00:32:14 +0900 Subject: [PATCH 05/17] =?UTF-8?q?refactor:=EA=B2=B0=ED=95=A9=EB=8F=84?= =?UTF-8?q?=EA=B0=80=20=EB=86=92=EC=9D=80=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=93=A4=EC=9D=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=A2=8B=EA=B2=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC,SRP=20=EC=9B=90=EC=B9=99=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chart/service/WebsocketSendService.java | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) 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 From 126229cef5b775e6ef866732a3f97949adb1f6e5 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 00:32:39 +0900 Subject: [PATCH 06/17] =?UTF-8?q?refactor:=EA=B2=B0=ED=95=A9=EB=8F=84?= =?UTF-8?q?=EA=B0=80=20=EB=86=92=EC=9D=80=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=93=A4=EC=9D=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=A2=8B=EA=B2=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC,SRP=20=EC=9B=90=EC=B9=99=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chart/service/RealTimeTradeService.java | 179 ++++++++++++------ 1 file changed, 116 insertions(+), 63 deletions(-) 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 From dc2eecb7c3e7571494aae59cc07cc568cb643572 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 00:57:52 +0900 Subject: [PATCH 07/17] =?UTF-8?q?refactor:=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=8B=9C=20CCMap=EC=97=90=EC=84=9C=20null=EC=9D=84=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8A=94=20=EC=98=88=EC=99=B8=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=9C=EA=B2=AC=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=B4=EC=84=9C=20NUll=EB=A1=9C=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EC=98=A8=EB=8B=A4=EB=A9=B4=20NPE=EB=A5=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=EC=8B=9C=ED=82=A4=EA=B2=8C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ChartSubscriptionService.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 From 23f30530a83e2be86db239572d811e9cbe583791 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 01:06:01 +0900 Subject: [PATCH 08/17] test:ChartSubscription unitTest --- .../service/ChartSubscriptionServiceTest.java | 494 ++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 src/test/java/com/cleanengine/coin/chart/service/ChartSubscriptionServiceTest.java 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 From 57ca2d7137822acefe050ff8a07453b6bd2c3569 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 01:06:29 +0900 Subject: [PATCH 09/17] test: MinuteOhlcDataService unitTest --- .../minute/MinuteOhlcDataServiceImplTest.java | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java 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..eb5ca874 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java @@ -0,0 +1,331 @@ +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.get(0); + 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) + ); + } + + 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 From 61b9d1ed81bdea7efc9e9f0aac09e6c2b849a232 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 01:06:43 +0900 Subject: [PATCH 10/17] test: RealTimeDataPrevRate unitTest --- .../RealTimeDataPrevRateServiceTest.java | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java 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..ec31f1f5 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java @@ -0,0 +1,188 @@ +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 = createMockTrade(); + } + + @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 createMockTrade() { + // Trade 엔티티 생성 (실제 구현에 따라 수정 필요) + Trade trade = new Trade(); + // setPrice가 있다고 가정하거나, 빌더 패턴 사용 + // trade.setPrice(100.0); + // 또는 리플렉션을 사용하여 필드 설정 + try { + java.lang.reflect.Field priceField = Trade.class.getDeclaredField("price"); + priceField.setAccessible(true); + priceField.set(trade, 100.0); + } catch (Exception e) { + // 실제 Trade 엔티티 구조에 맞게 수정 필요 + } + return trade; + } +} \ No newline at end of file From ca22a830e25dd163da3c7680a7e8984637eb178d Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 01:07:04 +0900 Subject: [PATCH 11/17] test: RealTimeOhlcService unitTest --- .../service/RealTimeOhlcServiceTest.java | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java 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..7a47ebfb --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java @@ -0,0 +1,441 @@ +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.getOpen()).isEqualTo(200.0); // reverse 후 첫 번째 + assertThat(result.getHigh()).isEqualTo(200.0); + assertThat(result.getLow()).isEqualTo(100.0); + assertThat(result.getClose()).isEqualTo(100.0); // reverse 후 마지막 + 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); + service.updateCache(validTicker, previousTime, null); // 이전 시간만 설정 + + // when + RealTimeOhlcService.TimeRange result = service.calculateTimeRange(validTicker, fixedNow); + + // then + assertThat(result.start()).isEqualTo(previousTime); + assertThat(result.end()).isEqualTo(fixedNow); + } + + // ===== getProcessedTradeData 테스트 ===== + @Test + @DisplayName("거래 데이터를 조회하고 역순으로 정렬한다") + void getProcessedTradeData_ValidTimeRange_ReturnsReversedTrades() { + // given + RealTimeOhlcService.TimeRange timeRange = new RealTimeOhlcService.TimeRange( + fixedNow.minusSeconds(1), fixedNow); + + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + validTicker, timeRange.start(), timeRange.end())) + .thenReturn(mockTrades); + + // when + List result = service.getProcessedTradeData(validTicker, timeRange); + + // then + assertThat(result).hasSize(3); + // 역순으로 정렬되었는지 확인 (원래 순서: 100, 150, 200 -> 역순: 200, 150, 100) + assertThat(result.get(0).getPrice()).isEqualTo(200.0); + assertThat(result.get(1).getPrice()).isEqualTo(150.0); + assertThat(result.get(2).getPrice()).isEqualTo(100.0); + } + + @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 From 1d3b2d9545be4ca382b9ac1ed03eb68ea262395d Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 01:07:18 +0900 Subject: [PATCH 12/17] test: RealTimeTradeService unitTest --- .../service/RealTimeTradeServiceTest.java | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java 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..c5518b85 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java @@ -0,0 +1,429 @@ +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()); + } +} \ No newline at end of file From 60407ee4778096dab3c7659836c80a6745a63b86 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 01:07:29 +0900 Subject: [PATCH 13/17] test: WebsocketSendService unitTest --- .../service/WebsocketSendServiceTest.java | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 src/test/java/com/cleanengine/coin/chart/service/WebsocketSendServiceTest.java 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 From b19906736a1e03241fa4489dd8a61b231c54ada1 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 18:14:38 +0900 Subject: [PATCH 14/17] =?UTF-8?q?refactor:=ED=95=84=EB=93=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EB=B0=A9=EC=8B=9D=EC=9D=84=20=EB=A6=AC=ED=94=8C?= =?UTF-8?q?=EB=A0=89=EC=85=98=EC=97=90=EC=84=9C=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RealTimeDataPrevRateServiceTest.java | 25 ++++--------- .../minute/MinuteOhlcDataServiceImplTest.java | 36 ++++++++++--------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java index ec31f1f5..e56d9800 100644 --- a/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java @@ -35,9 +35,9 @@ class RealTimeDataPrevRateServiceTest { @BeforeEach void setUp() { - tradeEventDto = new TradeEventDto("TRUMP", 0,150.0, LocalDateTime.now()); + tradeEventDto = new TradeEventDto("TRUMP", 0, 150.0, LocalDateTime.now()); currentTime = LocalDateTime.of(2024, 1, 15, 10, 30, 0); - mockTrade = createMockTrade(); + mockTrade = createTrade(currentTime); // ✅ 이제 100.0 가격으로 생성됨 } @Test @@ -55,7 +55,7 @@ void generatePrevRateData_WithYesterdayTrade_Success() { assertThat(result).isNotNull(); assertThat(result.getTicker()).isEqualTo("TRUMP"); assertThat(result.getCurrentPrice()).isEqualTo(150.0); - assertThat(result.getPrevClose()).isEqualTo(100.0); + assertThat(result.getPrevClose()).isEqualTo(100.0); // ✅ 성공 assertThat(result.getChangeRate()).isEqualTo(50.0); // (150-100)/100 * 100 assertThat(result.getTimestamp()).isEqualTo(currentTime); } @@ -95,7 +95,7 @@ void generatePrevRateData_WithCurrentTime_Success() { assertThat(result).isNotNull(); assertThat(result.getTicker()).isEqualTo("TRUMP"); assertThat(result.getCurrentPrice()).isEqualTo(150.0); - assertThat(result.getPrevClose()).isEqualTo(100.0); + assertThat(result.getPrevClose()).isEqualTo(100.0); // ✅ 성공 } @Test @@ -170,19 +170,8 @@ void getChangeRate_WithDecimalPrices_CalculatesCorrectly() { assertThat(result).isEqualTo(expected); } - private Trade createMockTrade() { - // Trade 엔티티 생성 (실제 구현에 따라 수정 필요) - Trade trade = new Trade(); - // setPrice가 있다고 가정하거나, 빌더 패턴 사용 - // trade.setPrice(100.0); - // 또는 리플렉션을 사용하여 필드 설정 - try { - java.lang.reflect.Field priceField = Trade.class.getDeclaredField("price"); - priceField.setAccessible(true); - priceField.set(trade, 100.0); - } catch (Exception e) { - // 실제 Trade 엔티티 구조에 맞게 수정 필요 - } - return trade; + //실제 엔티티를 만들어서 목업엔티티로 사용 + 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/minute/MinuteOhlcDataServiceImplTest.java b/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java index eb5ca874..93335a3e 100644 --- a/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java +++ b/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java @@ -56,7 +56,7 @@ void getMinuteOhlcData_ValidTicker_ReturnsOhlcData() { assertThat(result).isNotEmpty(); assertThat(result).hasSize(2); // 2분간의 데이터 - RealTimeOhlcDto firstMinute = result.get(0); + RealTimeOhlcDto firstMinute = result.getFirst(); assertThat(firstMinute.getTicker()).isEqualTo("BTC"); assertThat(firstMinute.getOpen()).isEqualTo(100.0); assertThat(firstMinute.getHigh()).isEqualTo(150.0); @@ -299,6 +299,7 @@ void calculateOhlcData_DecimalValues_CalculatesCorrectly() { assertThat(result.volume()).isEqualTo(3.75); // 1.5 + 2.25 } + // =====리플렉션 제거===== private List createMockTrades() { return List.of( // 첫 번째 분 (10:30) @@ -310,22 +311,23 @@ private List createMockTrades() { ); } + /** + * Trade 엔티티 생성 - @AllArgsConstructor 사용하여 리플렉션 제거 + * + * @param tradeTime 거래 시간 + * @param price 가격 + * @param size 거래량 + * @return Trade 객체 + */ 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); + 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 From 88a8149b2479da612952f78797fbf9b3bd570c10 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 18:18:36 +0900 Subject: [PATCH 15/17] =?UTF-8?q?Test:updateTradeCache,=20generateRealTime?= =?UTF-8?q?Data,=20calculateChangeRate=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20unit=20Test=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RealTimeTradeServiceTest.java | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java index c5518b85..e8fb109c 100644 --- a/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java @@ -426,4 +426,207 @@ void changeRateResult_Record_EqualsAndHashCode() { 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 From d2ad2cff2ac65ecb481dbef544cb5c6d38c86d72 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 18:20:12 +0900 Subject: [PATCH 16/17] =?UTF-8?q?Refactor:=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=9C=EA=B2=AC=EB=90=9C=20ohlcv?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD(List=EB=A5=BC=20reverse?= =?UTF-8?q?=EB=A5=BC=20=ED=95=98=EC=97=AC=20=EC=8B=9C=EC=9E=91=EA=B0=80=20?= =?UTF-8?q?=EC=A2=85=EA=B0=80=EB=A5=BC=20=EC=B0=BE=EC=95=98=EC=9C=BC?= =?UTF-8?q?=EB=82=98=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EB=B0=A9?= =?UTF-8?q?=EB=B2=95=EC=9D=B8=EA=B1=B0=EA=B0=99=EC=95=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chart/service/RealTimeOhlcService.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 4eb6f946..19298eb1 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java @@ -9,7 +9,6 @@ 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; @@ -68,14 +67,11 @@ TimeRange calculateTimeRange(String ticker, LocalDateTime now) { // 거래 데이터 조회 및 전처리 List getProcessedTradeData(String ticker, TimeRange timeRange) { - List recentTrades = tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + return tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( ticker, timeRange.start(), timeRange.end() ); - - Collections.reverse(recentTrades); - return recentTrades; } // 캐시 업데이트 @@ -104,15 +100,20 @@ RealTimeOhlcDto createOhlcDto(String ticker, LocalDateTime timestamp, calculateO // OHLCV 계산 메서드 @NotNull - static calculateOhlcv getCalculateOhlcv(List recentTrades) { - 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(); + 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) {} From a9e077cb6fd691fea0a990ff873b51084ee662fe Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 2 Jun 2025 18:21:14 +0900 Subject: [PATCH 17/17] =?UTF-8?q?Refactor:=EC=97=AD=EC=88=9C=EC=9D=84=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=ED=95=9C=20test=20=EC=82=AD=EC=A0=9C(?= =?UTF-8?q?=EB=B3=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=96=88=EA=B8=B0=20=EB=95=8C=EB=AC=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RealTimeOhlcServiceTest.java | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java index 7a47ebfb..b516bed8 100644 --- a/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java @@ -56,10 +56,8 @@ void getRealTimeOhlc_WithValidTrades_ReturnsOhlcData() { // then assertThat(result).isNotNull(); assertThat(result.getTicker()).isEqualTo("BTC"); - assertThat(result.getOpen()).isEqualTo(200.0); // reverse 후 첫 번째 assertThat(result.getHigh()).isEqualTo(200.0); assertThat(result.getLow()).isEqualTo(100.0); - assertThat(result.getClose()).isEqualTo(100.0); // reverse 후 마지막 assertThat(result.getVolume()).isEqualTo(6.0); // 1+2+3 } @@ -132,7 +130,12 @@ void calculateTimeRange_FirstCall_ReturnsOneSecondRange() { void calculateTimeRange_WithPreviousTime_ReturnsCustomRange() { // given LocalDateTime previousTime = fixedNow.minusSeconds(5); - service.updateCache(validTicker, previousTime, null); // 이전 시간만 설정 + + // 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); @@ -143,28 +146,6 @@ void calculateTimeRange_WithPreviousTime_ReturnsCustomRange() { } // ===== getProcessedTradeData 테스트 ===== - @Test - @DisplayName("거래 데이터를 조회하고 역순으로 정렬한다") - void getProcessedTradeData_ValidTimeRange_ReturnsReversedTrades() { - // given - RealTimeOhlcService.TimeRange timeRange = new RealTimeOhlcService.TimeRange( - fixedNow.minusSeconds(1), fixedNow); - - when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - validTicker, timeRange.start(), timeRange.end())) - .thenReturn(mockTrades); - - // when - List result = service.getProcessedTradeData(validTicker, timeRange); - - // then - assertThat(result).hasSize(3); - // 역순으로 정렬되었는지 확인 (원래 순서: 100, 150, 200 -> 역순: 200, 150, 100) - assertThat(result.get(0).getPrice()).isEqualTo(200.0); - assertThat(result.get(1).getPrice()).isEqualTo(150.0); - assertThat(result.get(2).getPrice()).isEqualTo(100.0); - } - @Test @DisplayName("빈 거래 데이터는 빈 리스트를 반환한다") void getProcessedTradeData_EmptyTrades_ReturnsEmptyList() {