diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedNotificationHandler.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedNotificationHandler.java new file mode 100644 index 00000000..8d94a666 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedNotificationHandler.java @@ -0,0 +1,53 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.trade.entity.Trade; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import static com.cleanengine.coin.common.CommonValues.BUY_ORDER_BOT_ID; +import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; + +@Slf4j +@Component +public class TradeExecutedNotificationHandler { + + private final SimpMessagingTemplate messagingTemplate; + + private static final String ASK = "ask"; // 매도 + private static final String BID = "bid"; // 매수 + + public TradeExecutedNotificationHandler(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + + @TransactionalEventListener + public void notifyAfterTradeExecuted(TradeExecutedEvent tradeExecutedEvent) { + Trade trade = tradeExecutedEvent.getTrade(); + if (trade == null) { + log.error("체결 알림 실패! trade == null"); + return ; + } + + Integer sellUserId = trade.getSellUserId(); + Integer buyUserId = trade.getBuyUserId(); + if (sellUserId == null || buyUserId == null) { + log.error("체결 알림 실패! sellUserId: {}, buyUserId: {}", sellUserId, buyUserId); + return ; + } + + if (sellUserId != SELL_ORDER_BOT_ID) { + TradeExecutedNotifyDto soldDto = TradeExecutedNotifyDto.of(trade, ASK); + messagingTemplate.convertAndSend("/topic/tradeNotification/" + sellUserId, soldDto); + } + if (buyUserId != BUY_ORDER_BOT_ID) { + TradeExecutedNotifyDto boughtDto = TradeExecutedNotifyDto.of(trade, BID); + messagingTemplate.convertAndSend("/topic/tradeNotification/" + buyUserId, boughtDto); + } + if (sellUserId != SELL_ORDER_BOT_ID || buyUserId != BUY_ORDER_BOT_ID) { + log.debug("{} 체결 이벤트 구독 : {}원에 {}개, 매수인: {}, 매도인: {}", trade.getTicker(), trade.getPrice(), trade.getSize(), buyUserId, sellUserId ); + } + } + +} diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedNotifyDto.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedNotifyDto.java new file mode 100644 index 00000000..10585a79 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedNotifyDto.java @@ -0,0 +1,43 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.trade.entity.Trade; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@JsonPropertyOrder({"ticker", "price", "size", "type", "tradedTime"}) +public class TradeExecutedNotifyDto { + + private String ticker; + + private Double price; + + private Double size; + + private String type; + + private LocalDateTime tradedTime; + + @Builder + private TradeExecutedNotifyDto(String ticker, Double price, Double size, String type, LocalDateTime tradedTime) { + this.ticker = ticker; + this.price = price; + this.size = size; + this.type = type; + this.tradedTime = tradedTime; + } + + public static TradeExecutedNotifyDto of(Trade trade, String type) { + return TradeExecutedNotifyDto.builder() + .ticker(trade.getTicker()) + .price(trade.getPrice()) + .size(trade.getSize()) + .type(type) + .tradedTime(trade.getTradeTime()) + .build(); + } + +} diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeExecutedNotificationHandlerTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeExecutedNotificationHandlerTest.java new file mode 100644 index 00000000..d3793153 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeExecutedNotificationHandlerTest.java @@ -0,0 +1,133 @@ +package com.cleanengine.coin.trade.application; + +import static com.cleanengine.coin.common.CommonValues.BUY_ORDER_BOT_ID; +import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import java.time.LocalDateTime; + +@DisplayName("체결 알림 단위테스트") +@ExtendWith(MockitoExtension.class) +class TradeExecutedNotificationHandlerTest { + + @Mock + private SimpMessagingTemplate messagingTemplate; + + private TradeExecutedNotificationHandler handler; + + @BeforeEach + void setUp() { + handler = new TradeExecutedNotificationHandler(messagingTemplate); + } + + @DisplayName("매도인은 봇인 정상 체결내역을 리스닝하면 웹소켓으로 전송한다.") + @Test + void shouldSendNotificationsForValidTrade() { + // given + Trade trade = Trade.of("BTC", LocalDateTime.now(), 3, SELL_ORDER_BOT_ID, 50000.0, 1.0); + TradeExecutedEvent event = TradeExecutedEvent.of(trade, null, null); + + // when + handler.notifyAfterTradeExecuted(event); + + // then + verify(messagingTemplate, times(1)).convertAndSend(eq("/topic/tradeNotification/3"), any(TradeExecutedNotifyDto.class)); + verify(messagingTemplate).convertAndSend(eq("/topic/tradeNotification/3"), any(TradeExecutedNotifyDto.class)); + } + + @DisplayName("매수인은 봇인 정상 체결내역을 리스닝하면 웹소켓으로 전송한다.") + @Test + void shouldSendNotificationsForValidTrade2() { + // given + Trade trade = Trade.of("BTC", LocalDateTime.now(), BUY_ORDER_BOT_ID, 3, 50000.0, 1.0); + TradeExecutedEvent event = TradeExecutedEvent.of(trade, null, null); + + // when + handler.notifyAfterTradeExecuted(event); + + // then + verify(messagingTemplate, times(1)).convertAndSend(eq("/topic/tradeNotification/3"), any(TradeExecutedNotifyDto.class)); + verify(messagingTemplate).convertAndSend(eq("/topic/tradeNotification/3"), any(TradeExecutedNotifyDto.class)); + } + + @DisplayName("매수인과 매도인의 userId가 null이면 메시지를 전송하지 않는다.") + @Test + void shouldNotSendNotificationForNullUserIds() { + // given + Trade trade = Trade.of("BTC", LocalDateTime.now(), null, null, 50000.0, 1.0); + TradeExecutedEvent event = TradeExecutedEvent.of(trade, null, null); + + // when + handler.notifyAfterTradeExecuted(event); + + // then + verifyNoInteractions(messagingTemplate); + } + + @DisplayName("매수인의 userId가 null이면 메시지를 전송하지 않는다.") + @Test + void shouldNotSendNotificationForNullBuyUserId() { + // given + Trade trade = Trade.of("BTC", LocalDateTime.now(), null, SELL_ORDER_BOT_ID, 50000.0, 1.0); + TradeExecutedEvent event = TradeExecutedEvent.of(trade, null, null); + + // when + handler.notifyAfterTradeExecuted(event); + + // then + verifyNoInteractions(messagingTemplate); + } + + @DisplayName("매도인의 userId가 null이면 메시지를 전송하지 않는다.") + @Test + void shouldNotSendNotificationForNullSellUserId() { + // given + Trade trade = Trade.of("BTC", LocalDateTime.now(), BUY_ORDER_BOT_ID, null, 50000.0, 1.0); + TradeExecutedEvent event = TradeExecutedEvent.of(trade, null, null); + + // when + handler.notifyAfterTradeExecuted(event); + + // then + verifyNoInteractions(messagingTemplate); + } + + @DisplayName("봇끼리의 체결은 메시지를 전송하지 않는다.") + @Test + void shouldNotSendNotificationForBotTrade() { + // given + Trade trade = Trade.of("BTC", LocalDateTime.now(), BUY_ORDER_BOT_ID, SELL_ORDER_BOT_ID, 50000.0, 1.0); + TradeExecutedEvent event = TradeExecutedEvent.of(trade, null, null); + + // when + handler.notifyAfterTradeExecuted(event); + + // then + verifyNoInteractions(messagingTemplate); + } + + @DisplayName("체결이 null이면 메시지를 전송하지 않는다.") + @Test + void shouldNotSendNotificationForNullTrade() { + // given + TradeExecutedEvent event = TradeExecutedEvent.of(null, null, null); + + // when + handler.notifyAfterTradeExecuted(event); + + // then + verifyNoInteractions(messagingTemplate); + } + +} \ No newline at end of file