diff --git a/.github/workflows/code-analyze-sonarqube.yml b/.github/workflows/code-analyze-sonarqube.yml index 5c258ca8..a2bf6f8d 100644 --- a/.github/workflows/code-analyze-sonarqube.yml +++ b/.github/workflows/code-analyze-sonarqube.yml @@ -12,6 +12,7 @@ on: - 'src/**' push: branches: + - main - dev paths: - 'src/**' diff --git a/README.md b/README.md new file mode 100644 index 00000000..522858a7 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Invest Future +> 만약에 이때(IF) 투자했다면 지금 얼마를 벌었을까?
+> +> 항상 저희는 상상을 하곤 합니다. ~~(이 때 돈 넣었으면 지금 4배는 벌었는데)~~
+> 하지만 현실은 돈이 부족하기 떄문에 투자를 하기 쉽지 않습니다.
+>
+> IF에서는 모의투자를 통해 투자에 대한 경험을 쌓고, 투자에 대한 이해를 높일 수 있습니다.
+
+- 배포 URL : https://investfuture.my +

+ +## 다른 모의투자 서비스와의 차이점 + 많은 모의투자서비스는 3가지 유형이 있습니다. + 1. 턴제 모의투자 + 2. 실시간 모의투자 (거래가 차트에 반영되지 않음) + 3. 자체 시장 모의투자 (거래가 차트에 반영 됨) +
+ + + + + + + + + + + + + + + + +
ImageImageImage
시장 개입 여부DB 지원 여부봇 지원 여부
+ + +기존 모의투자 서비스는 실제 거래소의 시세를 지원하면 사용자의 투자 행동이 차트에 반영되지 않거나,
+차트에 반영되지만 실제 시장과는 다른 자체 시장을 형성하는 서비스가 제공되고 있습니다. + + Invest Future(IF)는 실제 시장의 추세를 따라가고, 사용자의 투자 행동이 차트에 반영되는 모의투자 서비스입니다. + + +
+
+ + +## How it works? + +> 기존 실시간 모의투자 서비스에서 사용자의 투자 행동이 차트에 반영되지 않는 이유는 사용자의 투자 행동이 차트에 반영될경우 시간이 지날수록 실제 시장의 차트와 모의투자 서비스의 차트의 괴리가 커지기 때문입니다.
+> 비유하자면 시간이 지날수록 멀티버스가 발생합니다. + +Image +Invest Future(IF)는 사용자의 투자 행동으로 서비스의 차트와 실제 시장의 차트의 괴리가 커지면 자체 매수봇과 매도봇이 매수 매도를 하면서 실제 시장과의 차이를 보간합니다. + + + + +## 프로젝트 특징 + +Image + +### 세부 로직 +
+ 주문/체결 +
+ Image +

설명 :

+
+
+ +
+ 보간 +
+ Image +

설명 :

+
+
+ +
+ 차트/DB +
+ Image +

설명 :

+
+
+ + + +## 1차 구현 [5.2 ~ 5.15] + +코인 한 개(트럼프 코인)을 매수 할 수 있습니다. +사용자의 주문에 따라 그래프차트와 호가창, 실시간 체결창에 반영됩니다. + +https://github.com/user-attachments/assets/779b77f1-e778-4a53-b37f-d79a2dc187e8 diff --git a/src/main/java/com/cleanengine/coin/chart/handler/TradeEventHandler.java b/src/main/java/com/cleanengine/coin/chart/handler/TradeEventHandler.java index 7b5d9270..db294c80 100644 --- a/src/main/java/com/cleanengine/coin/chart/handler/TradeEventHandler.java +++ b/src/main/java/com/cleanengine/coin/chart/handler/TradeEventHandler.java @@ -12,8 +12,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; -import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; @Component @RequiredArgsConstructor @@ -25,7 +25,7 @@ public class TradeEventHandler { private final RealTimeDataPrevRateService realTimeDataPrevRateService; //event로 이벤틀 처리해야한다. //eventListener는 void로 처리를 해야한다 - @EventListener + @TransactionalEventListener public void handleTradeEvent(TradeExecutedEvent event) { Trade trade = event.getTrade(); if (trade == null) { diff --git a/src/main/java/com/cleanengine/coin/common/error/readme.md b/src/main/java/com/cleanengine/coin/common/error/readme.md deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/cleanengine/coin/order/application/OrderRestoreService.java b/src/main/java/com/cleanengine/coin/order/application/OrderRestoreService.java index cf28ed4b..eed137ae 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderRestoreService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderRestoreService.java @@ -38,6 +38,6 @@ public void run(ApplicationArguments args) throws Exception { protected void restoreOrder(Order order){ WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(order.getTicker()); waitingOrders.addOrder(order); - updateOrderBookUsecase.updateOrderBookOnNewOrder(order); + updateOrderBookUsecase.updateOrderBookOnRestored(order); } } diff --git a/src/main/java/com/cleanengine/coin/order/readme.md b/src/main/java/com/cleanengine/coin/order/readme.md deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java b/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java index 3360b9e9..51ee275e 100644 --- a/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java +++ b/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java @@ -25,12 +25,21 @@ public class OrderBookService implements UpdateOrderBookUsecase, ReadOrderBookUs @Override public void updateOrderBookOnNewOrder(Order order) { + addToOrderBook(order, order.getOrderSize()); + } + + @Override + public void updateOrderBookOnRestored(Order order) { + addToOrderBook(order, order.getRemainingSize()); + } + + private void addToOrderBook(Order order, double size) { if(order.getIsMarketOrder()){return;} String ticker = order.getTicker(); activeOrders(ticker).saveOrder(order); boolean isBuyOrder = order instanceof BuyOrder; - orderBookDomainService.updateOrderBookOnNewOrder(ticker, isBuyOrder, order.getPrice(), order.getRemainingSize()); + orderBookDomainService.updateOrderBookOnNewOrder(ticker, isBuyOrder, order.getPrice(), size); sendOrderBookUpdated(ticker); } diff --git a/src/main/java/com/cleanengine/coin/orderbook/application/service/UpdateOrderBookUsecase.java b/src/main/java/com/cleanengine/coin/orderbook/application/service/UpdateOrderBookUsecase.java index ec1c9cd2..8d28e23a 100644 --- a/src/main/java/com/cleanengine/coin/orderbook/application/service/UpdateOrderBookUsecase.java +++ b/src/main/java/com/cleanengine/coin/orderbook/application/service/UpdateOrderBookUsecase.java @@ -5,5 +5,6 @@ // TODO 메서드 parameter가 비슷한 스타일이어야 한다. public interface UpdateOrderBookUsecase { void updateOrderBookOnNewOrder(Order order); + void updateOrderBookOnRestored(Order order); void updateOrderBookOnTradeExecuted(String ticker, Long buyOrderId, Long sellOrderId, Double orderSize); } diff --git a/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java b/src/main/java/com/cleanengine/coin/orderbook/infra/SpringOrderCreatedUpdateOrderBookHandler.java similarity index 85% rename from src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java rename to src/main/java/com/cleanengine/coin/orderbook/infra/SpringOrderCreatedUpdateOrderBookHandler.java index aef0fbd9..8ad6fe20 100644 --- a/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java +++ b/src/main/java/com/cleanengine/coin/orderbook/infra/SpringOrderCreatedUpdateOrderBookHandler.java @@ -1,8 +1,9 @@ -package com.cleanengine.coin.order.adapter.in.event; +package com.cleanengine.coin.orderbook.infra; import com.cleanengine.coin.order.application.event.OrderCreated; import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; @@ -11,6 +12,7 @@ public class SpringOrderCreatedUpdateOrderBookHandler { private final UpdateOrderBookUsecase updateOrderBookUsecase; + @Order(1) @TransactionalEventListener public void handleOrderCreated(OrderCreated event) { updateOrderBookUsecase.updateOrderBookOnNewOrder(event.order()); diff --git a/src/main/java/com/cleanengine/coin/realitybot/readme.md b/src/main/java/com/cleanengine/coin/realitybot/readme.md deleted file mode 100644 index e69de29b..00000000 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/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java index 1e96fbfa..a7256f70 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java @@ -12,11 +12,12 @@ import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.application.AccountService; import com.cleanengine.coin.user.info.application.WalletService; -import jakarta.transaction.Transactional; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -33,7 +34,7 @@ public class TradeExecutor { private final TradeExecutedEventPublisher tradeExecutedEventPublisher; private final TradeService tradeService; - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void executeTrade(WaitingOrders waitingOrders, TradePair tradePair, String ticker) { BuyOrder buyOrder = tradePair.getBuyOrder(); SellOrder sellOrder = tradePair.getSellOrder(); @@ -48,7 +49,7 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradedPrice = tradeUnitPriceAndSize.tradedPrice(); if (approxEquals(tradedSize, 0.0)) { log.debug("체결 중단! 체결 시도 수량 : {}, 매수단가 : {}, 매도단가 : {}", tradedSize, buyOrder.getPrice(), sellOrder.getPrice()); - return; + return ; } this.writeTradingLog(buyOrder, sellOrder); diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java index 5a7cab22..df367924 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java @@ -2,11 +2,10 @@ import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -17,14 +16,25 @@ public class TradeFlowService { private final TradeMatcher tradeMatcher; private final TradeExecutor tradeExecutor; + private final WaitingOrdersManager waitingOrdersManager; - @Transactional(propagation = Propagation.REQUIRES_NEW) public void execMatchAndTrade(String ticker) { - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); // TODO : peek() 해온 Order 객체들을 lock -> 체결 도중 취소 방지 Optional> tradePair = tradeMatcher.matchOrders(waitingOrders); + boolean continueProcessing = tradePair.isPresent(); - tradePair.ifPresent(orderOrderTradePair -> tradeExecutor.executeTrade(waitingOrders, orderOrderTradePair, ticker)); + while (continueProcessing) { + try { + tradeExecutor.executeTrade(waitingOrders, tradePair.get(), ticker); + tradePair = tradeMatcher.matchOrders(waitingOrders); + continueProcessing = tradePair.isPresent(); + } catch (Exception e) { + // TODO : 회복 필요 + log.error("Error processing trades for {}: {}", ticker, e.getMessage()); + continueProcessing = false; + } + } } } diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeMatcher.java b/src/main/java/com/cleanengine/coin/trade/application/TradeMatcher.java index 11bfa6ba..0fe3e3e4 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeMatcher.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeMatcher.java @@ -5,7 +5,6 @@ import com.cleanengine.coin.order.domain.OrderType; import com.cleanengine.coin.order.domain.SellOrder; import com.cleanengine.coin.order.domain.spi.WaitingOrders; -import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -15,20 +14,10 @@ @Component public class TradeMatcher { - private final WaitingOrdersManager waitingOrdersManager; - // 1초마다 로깅 private long lastLogTime = 0; private static final long LOG_INTERVAL = 1000; - public TradeMatcher(WaitingOrdersManager waitingOrdersManager) { - this.waitingOrdersManager = waitingOrdersManager; - } - - public WaitingOrders getWaitingOrders(String ticker) { - return waitingOrdersManager.getWaitingOrders(ticker); - } - public Optional> matchOrders(WaitingOrders waitingOrders) { // 반환값 : 체결여부 this.writeQueueLog(waitingOrders); diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java index 46d84d26..0b198e55 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java @@ -1,6 +1,5 @@ package com.cleanengine.coin.trade.application; -import com.cleanengine.coin.order.application.event.OrderCreated; import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; @@ -19,12 +18,9 @@ public TradeQueueManager(TradeFlowService tradeFlowService) { } @EventListener - public void handleOrderInserted(OrderInsertedToQueue event) { - try { - tradeFlowService.execMatchAndTrade(event.order().getTicker()); - } catch (Exception e) { - log.error("Error processing trades for {}: {}", event.order().getTicker(), e.getMessage()); - } + public void handleOrderInserted(OrderInsertedToQueue orderInsertedToQueue) { + String ticker = orderInsertedToQueue.order().getTicker(); + tradeFlowService.execMatchAndTrade(ticker); } } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/readme.md b/src/main/java/com/cleanengine/coin/trade/readme.md deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/cleanengine/coin/user/domain/OAuth.java b/src/main/java/com/cleanengine/coin/user/domain/OAuth.java index 409974a3..ea214299 100644 --- a/src/main/java/com/cleanengine/coin/user/domain/OAuth.java +++ b/src/main/java/com/cleanengine/coin/user/domain/OAuth.java @@ -1,7 +1,6 @@ package com.cleanengine.coin.user.domain; import jakarta.persistence.*; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -13,7 +12,6 @@ @Entity @Table(name = "oauth") @NoArgsConstructor -@AllArgsConstructor public class OAuth { @Id diff --git a/src/main/java/com/cleanengine/coin/user/domain/User.java b/src/main/java/com/cleanengine/coin/user/domain/User.java index 0f12f3e8..fb36bd44 100644 --- a/src/main/java/com/cleanengine/coin/user/domain/User.java +++ b/src/main/java/com/cleanengine/coin/user/domain/User.java @@ -14,7 +14,6 @@ @Getter @Setter @NoArgsConstructor -@AllArgsConstructor public class User { @Id @@ -26,4 +25,13 @@ public class User { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + private User(Integer id, LocalDateTime createdAt) { + this.id = id; + this.createdAt = createdAt; + } + + public static User of(Integer id, LocalDateTime createdAt) { + return new User(id, createdAt); + } + } diff --git a/src/main/java/com/cleanengine/coin/user/domain/Wallet.java b/src/main/java/com/cleanengine/coin/user/domain/Wallet.java index df793cc8..b068b5fa 100644 --- a/src/main/java/com/cleanengine/coin/user/domain/Wallet.java +++ b/src/main/java/com/cleanengine/coin/user/domain/Wallet.java @@ -1,10 +1,7 @@ package com.cleanengine.coin.user.domain; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; @Setter @Getter @@ -34,6 +31,35 @@ public class Wallet { @Column(name = "roi") private Double roi; // Return on Investment (수익률) + @Builder + private Wallet(String ticker, Integer accountId, Double size, Double buyPrice, Double roi) { + this.ticker = ticker; + this.accountId = accountId; + this.size = size; + this.buyPrice = buyPrice; + this.roi = roi; + } + + public static Wallet of(String ticker, Integer accountId) { + return Wallet.builder() + .ticker(ticker) + .accountId(accountId) + .size(0.0) + .buyPrice(0.0) + .roi(0.0) + .build(); + } + + public static Wallet of(String ticker, Integer accountId, Double size) { + return Wallet.builder() + .ticker(ticker) + .accountId(accountId) + .size(size) + .buyPrice(0.0) + .roi(0.0) + .build(); + } + public static Wallet generateEmptyWallet(String ticker, Integer accountId){ Wallet wallet = new Wallet(); wallet.setTicker(ticker); diff --git a/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java b/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java index dc33b957..2dd61722 100644 --- a/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java +++ b/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java @@ -1,7 +1,7 @@ package com.cleanengine.coin.user.info.application; -import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.AccountRepository; import com.cleanengine.coin.user.info.infra.WalletRepository; import org.springframework.stereotype.Service; @@ -11,14 +11,14 @@ public class WalletService { private final WalletRepository walletRepository; - private final AccountService accountService; + private final AccountRepository accountRepository; - public WalletService(WalletRepository walletRepository, AccountService accountService) { + public WalletService(WalletRepository walletRepository, AccountRepository accountRepository) { this.walletRepository = walletRepository; - this.accountService = accountService; + this.accountRepository = accountRepository; } - public List retrieveWalletsByAccountId(Integer accountId) { + public List findByAccountId(Integer accountId) { return walletRepository.findByAccountId(accountId); } @@ -27,19 +27,9 @@ public Wallet save(Wallet wallet) { } public Wallet findWalletByUserIdAndTicker(Integer userId, String ticker) { - Account account = accountService.findAccountByUserId(userId).orElseThrow(); - return walletRepository.findByAccountIdAndTicker(account.getId(), ticker) - .orElseGet(() -> createNewWallet(account.getId(), ticker)); - } - - public Wallet createNewWallet(Integer accountId, String ticker) { - Wallet newWallet = new Wallet(); - newWallet.setAccountId(accountId); - newWallet.setTicker(ticker); - newWallet.setSize(0.0); - newWallet.setBuyPrice(0.0); - newWallet.setRoi(0.0); - return newWallet; + int accountId = accountRepository.findByUserId(userId).orElseThrow().getId(); + return walletRepository.findByAccountIdAndTicker(accountId, ticker) + .orElseGet(() -> Wallet.of(ticker, accountId)); } } diff --git a/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java b/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java index 60580b4c..0ad1bbf5 100644 --- a/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java +++ b/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java @@ -41,7 +41,7 @@ public ApiResponse retrieveUserInfo() { return ApiResponse.fail(ErrorResponse.of(ErrorStatus.UNAUTHORIZED_RESOURCE)); } Account account = accountService.retrieveAccountByUserId(userId); - List wallets = walletService.retrieveWalletsByAccountId(account.getId()); + List wallets = walletService.findByAccountId(account.getId()); userInfoDTO.setWallets(wallets); return ApiResponse.success(userInfoDTO, HttpStatus.OK); diff --git a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java index d39e163d..22717307 100644 --- a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java +++ b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java @@ -31,7 +31,7 @@ public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oA @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); + OAuth2User oAuth2User = doSuperLoadMethod(userRequest); OAuth2Response oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); /* 추후 OAuth 플랫폼 추가 시 이런 식으로 Response 분기처리 @@ -64,18 +64,24 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic oAuthRepository.save(newOAuth); accountService.createNewAccount(newUser.getId(), CommonValues.INITIAL_USER_CASH); - return new CustomOAuth2User(UserOAuthDetails.of(newUser, newOAuth)); + UserOAuthDetails newUserOAuthDetails = UserOAuthDetails.of(newUser, newOAuth); + return CustomOAuth2User.of(newUserOAuthDetails); } else { OAuth existOAuth = oAuthRepository.findByProviderAndProviderUserId(provider, providerUserId); existOAuth.setEmail(email); - existOAuth.setNickname(oAuth2Response.getName()); - // TODO : KAKAO Token 관련 정보 추가 + existOAuth.setNickname(name); oAuthRepository.save(existOAuth); - return new CustomOAuth2User(existData); + existData.update(existOAuth); + + return CustomOAuth2User.of(existData); } } + protected OAuth2User doSuperLoadMethod(OAuth2UserRequest userRequest) { + return super.loadUser(userRequest); + } + } diff --git a/src/main/java/com/cleanengine/coin/user/login/application/CustomSuccessHandler.java b/src/main/java/com/cleanengine/coin/user/login/application/CustomSuccessHandler.java index c56bc052..463f4e02 100644 --- a/src/main/java/com/cleanengine/coin/user/login/application/CustomSuccessHandler.java +++ b/src/main/java/com/cleanengine/coin/user/login/application/CustomSuccessHandler.java @@ -16,14 +16,16 @@ public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final JWTUtil jwtUtil; - @Value("${spring.security.cookie.secure}") - private boolean isCookieSecure; + private final boolean isCookieSecure; - @Value("${frontend.url}") - private String frontendUrl; + private final String frontendUrl; - public CustomSuccessHandler(JWTUtil jwtUtil) { + public CustomSuccessHandler(JWTUtil jwtUtil, + @Value("${spring.security.cookie.secure}") boolean isCookieSecure, + @Value("${frontend.url}") String frontendUrl) { this.jwtUtil = jwtUtil; + this.isCookieSecure = isCookieSecure; + this.frontendUrl = frontendUrl; } @Override diff --git a/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java b/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java index 89e9e942..b6dc9dc4 100644 --- a/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java +++ b/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java @@ -83,7 +83,7 @@ protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServlet private static Authentication getAuthentication(Integer userId) { UserOAuthDetails userOAuthDetails = UserOAuthDetails.of(userId); - CustomOAuth2User customOAuth2User = new CustomOAuth2User(userOAuthDetails); + CustomOAuth2User customOAuth2User = CustomOAuth2User.of(userOAuthDetails); // 스프링 시큐리티 인증 토큰 생성 return new UsernamePasswordAuthenticationToken(customOAuth2User, diff --git a/src/main/java/com/cleanengine/coin/user/login/infra/CustomOAuth2User.java b/src/main/java/com/cleanengine/coin/user/login/infra/CustomOAuth2User.java index 209826bf..da126a21 100644 --- a/src/main/java/com/cleanengine/coin/user/login/infra/CustomOAuth2User.java +++ b/src/main/java/com/cleanengine/coin/user/login/infra/CustomOAuth2User.java @@ -11,10 +11,14 @@ public class CustomOAuth2User implements OAuth2User { private final UserOAuthDetails userOAuthDetails; - public CustomOAuth2User(UserOAuthDetails userOAuthDetails) { + private CustomOAuth2User(UserOAuthDetails userOAuthDetails) { this.userOAuthDetails = userOAuthDetails; } + public static CustomOAuth2User of(UserOAuthDetails userOAuthDetails) { + return new CustomOAuth2User(userOAuthDetails); + } + @Override public Map getAttributes() { return null; diff --git a/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java b/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java index d90ef467..fc0b55b1 100644 --- a/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java +++ b/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java @@ -43,4 +43,9 @@ public static UserOAuthDetails of(int userId) { .build(); } + public void update(OAuth oauth) { + this.email = oauth.getEmail(); + this.name = oauth.getNickname(); + } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7d298092..b1ca5d89 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,7 @@ spring: jackson: time-zone: Asia/Seoul jpa: + open-in-view: false properties: hibernate: jdbc: diff --git a/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java b/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java index 96695066..4e16bcb1 100644 --- a/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java +++ b/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java @@ -15,7 +15,7 @@ public SecurityContext createSecurityContext(WithCustomMockUser annotation) { SecurityContext context = SecurityContextHolder.createEmptyContext(); UserOAuthDetails userOAuthDetails = UserOAuthDetails.of(annotation.id()); - CustomOAuth2User customOAuth2User = new CustomOAuth2User(userOAuthDetails); + CustomOAuth2User customOAuth2User = CustomOAuth2User.of(userOAuthDetails); Authentication authentication = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities()); 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 diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java similarity index 96% rename from src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceTest.java rename to src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java index f3ad8b87..fd75eb3a 100644 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceTest.java +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java @@ -23,9 +23,9 @@ @ActiveProfiles({"dev", "it", "h2-mem"}) @SpringBootTest -@DisplayName("체결 처리 통합테스트") +@DisplayName("체결처리 h2 통합테스트") @Disabled -class TradeFlowServiceTest { +class TradeFlowServiceIntegrationTest { private static TradeBatchProcessor staticTradeBatchProcessor; @@ -41,10 +41,9 @@ class TradeFlowServiceTest { TradeBatchProcessor tradeBatchProcessor; @Autowired private WaitingOrdersManager waitingOrdersManager; - @Autowired - TradeMatcher tradeMatcher; private final String ticker = "BTC"; + private final WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); @BeforeEach void setUp() { @@ -76,7 +75,6 @@ void testLimitToLimitCompleteTrade() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); @@ -122,7 +120,6 @@ void testLimitToLimitPartialTrade1() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); @@ -161,7 +158,6 @@ void testLimitToLimitPartialTrade2() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); @@ -200,7 +196,6 @@ void testMarketToLimitCompleteTrade1() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); @@ -239,7 +234,6 @@ void testMarketToLimitCompleteTrade2() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); @@ -278,7 +272,6 @@ void testMarketToLimitPartialTrade1() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); @@ -317,7 +310,6 @@ void testMarketToLimitPartialTrade2() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); @@ -361,7 +353,6 @@ void testMarketToLimitPartialTrade3() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); @@ -400,7 +391,6 @@ void testMarketToLimitPartialTrade4() { buyOrderRepository.save(buyOrder); sellOrderRepository.save(sellOrder); - WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); waitingOrders.addOrder(buyOrder); waitingOrders.addOrder(sellOrder); diff --git a/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java b/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java index 1a5efff8..46f3b595 100644 --- a/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java +++ b/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; -@ActiveProfiles({"dev", "it", "h2-mem"}) +@ActiveProfiles({"dev", "h2-mem"}) @DisplayName("계좌 서비스 - h2 통합테스트") @SpringBootTest class AccountServiceTest { @@ -22,7 +22,7 @@ class AccountServiceTest { @Test void createNewAccount() { // given - int userId = 3; + int userId = Integer.MAX_VALUE; double cash = CommonValues.INITIAL_USER_CASH; // when @@ -41,7 +41,7 @@ void createNewAccount() { @Test void retrieveAccountByInvalidUserId() { // given, when - Account account = accountService.retrieveAccountByUserId(1000); + Account account = accountService.retrieveAccountByUserId(Integer.MAX_VALUE - 1); // then assertThat(account).isNull(); diff --git a/src/test/java/com/cleanengine/coin/user/info/application/WalletServiceTest.java b/src/test/java/com/cleanengine/coin/user/info/application/WalletServiceTest.java new file mode 100644 index 00000000..2ec3e644 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/info/application/WalletServiceTest.java @@ -0,0 +1,95 @@ +package com.cleanengine.coin.user.info.application; + +import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("지갑 서비스-Repository 통합테스트") +@ActiveProfiles({"dev", "h2-mem"}) +@Transactional +@SpringBootTest +class WalletServiceTest { + + @Autowired + private WalletService walletService; + + @Autowired + private WalletRepository walletRepository; + + @Autowired + private AccountRepository accountRepository; + + private Account testAccount; + + @BeforeEach + void setUp() { + // given + testAccount = Account.of(3, 0.0); + accountRepository.save(testAccount); + + Wallet testWallet = Wallet.of("BTC", testAccount.getId(), 1000.0); + walletRepository.save(testWallet); + } + + @DisplayName("계좌 ID로 조회 시 지갑이 정상 반환된다.") + @Test + void findByAccountId_thenReturnWallet() { + // when + List wallets = walletService.findByAccountId(testAccount.getId()); + + // then + assertThat(wallets).isNotEmpty(); + assertThat(wallets.getFirst().getTicker()).isEqualTo("BTC"); + } + + @DisplayName("지갑이 성공적으로 저장된다.") + @Test + void save_thenCreateNewWallet() { + // when + Wallet newWallet = Wallet.of("TRUMP", testAccount.getId(), 5000.0); + Wallet savedWallet = walletService.save(newWallet); + + // then + assertThat(savedWallet.getId()).isNotNull(); + assertThat(savedWallet.getTicker()).isEqualTo("TRUMP"); + assertThat(savedWallet.getSize()).isEqualTo(5000.0); + } + + @DisplayName("유저 ID, 티커로 존재하는 지갑 조회 시 정상적으로 반환된다.") + @Test + void findWalletByUserIdAndTicker_ExistingWallet_thenReturnWallet() { + // when + Wallet wallet = walletService.findWalletByUserIdAndTicker(testAccount.getUserId(), "BTC"); + + // then + assertThat(wallet).isNotNull(); + assertThat(wallet.getId()).isNotNull(); + assertThat(wallet.getTicker()).isEqualTo("BTC"); + assertThat(wallet.getSize()).isEqualTo(1000.0); + } + + @DisplayName("유저 ID, 티커로 존재하지 않는 지갑 조회 시 빈 지갑이 새로 반환된다.") + @Test + void findWalletByUserIdAndTicker_NonExistingWallet_thenReturnEmptyWallet() { + // when + Wallet wallet = walletService.findWalletByUserIdAndTicker(testAccount.getUserId(), "TRUMP"); + + // then + assertThat(wallet).isNotNull(); + assertThat(wallet.getTicker()).isEqualTo("TRUMP"); + assertThat(wallet.getSize()).isEqualTo(0.0); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java b/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java index 7e77629e..dcf80af7 100644 --- a/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java +++ b/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java @@ -101,7 +101,7 @@ public void testRetrieveUserInfoSuccess() throws Exception { verify(userService, times(1)).retrieveUserInfoByUserId(userId); verify(accountService, times(1)).retrieveAccountByUserId(userId); - verify(walletService, times(1)).retrieveWalletsByAccountId(account.getId()); + verify(walletService, times(1)).findByAccountId(account.getId()); } @Test diff --git a/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java b/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java new file mode 100644 index 00000000..13c3f7b1 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java @@ -0,0 +1,144 @@ +package com.cleanengine.coin.user.login.application; + +import com.cleanengine.coin.common.CommonValues; +import com.cleanengine.coin.user.domain.OAuth; +import com.cleanengine.coin.user.domain.User; +import com.cleanengine.coin.user.info.application.AccountService; +import com.cleanengine.coin.user.login.infra.CustomOAuth2User; +import com.cleanengine.coin.user.login.infra.UserOAuthDetails; +import com.cleanengine.coin.user.info.infra.OAuthRepository; +import com.cleanengine.coin.user.info.infra.UserRepository; +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@DisplayName("OAuth2 유저 서비스 단위테스트") +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CustomOAuth2UserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private OAuthRepository oAuthRepository; + @Mock + private AccountService accountService; + @Mock + private OAuth2UserRequest userRequest; + @Mock + private OAuth2User oAuth2UserFromSuper; + @Mock + private ClientRegistration clientRegistration; + + private CustomOAuth2UserService customOAuth2UserService; + + private final String provider = "kakao"; + + private final String providerId = "12345"; + + @BeforeEach + void setUp() { + // DefaultOAuth2UserService.loadUser만 mocking하기 위해 spy 사용 + customOAuth2UserService = Mockito.spy(new CustomOAuth2UserService(userRepository, oAuthRepository, accountService)); + + Map profile = Map.of("nickname", "Test User"); + Map kakaoAccount = Map.of( + "email", "test@example.com", + "profile", profile + ); + Map attributes = Map.of( + "id", providerId, + "kakao_account", kakaoAccount + ); + when(oAuth2UserFromSuper.getAttributes()).thenReturn(attributes); + + when(userRequest.getClientRegistration()).thenReturn(clientRegistration); + when(clientRegistration.getRegistrationId()).thenReturn(provider); + + try { + doReturn(oAuth2UserFromSuper) + .when(customOAuth2UserService) + .doSuperLoadMethod(userRequest); + } catch (OAuth2AuthenticationException e) { + fail("doSuperLoadMethod mocking 실패", e); + } + } + + @DisplayName("신규 유저로 인증 시 User와 OAuth를 새로 생성한다.") + @Test + void whenNewUser_thenCreateUserAndOAuth() { + // Given + int userId = 3; + when(userRepository.findUserByOAuthProviderAndProviderId(provider, providerId)).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(userId); + return user; + }); + + // When + OAuth2User result = customOAuth2UserService.loadUser(userRequest); + + // Then + assertNotNull(result); + verify(userRepository).save(any(User.class)); + verify(oAuthRepository).save(any(OAuth.class)); + verify(accountService).createNewAccount(eq(userId), eq(CommonValues.INITIAL_USER_CASH)); + + assertInstanceOf(CustomOAuth2User.class, result); + assertEquals("Test User", result.getName()); + } + + @DisplayName("기존 유저로 인증 시 이메일과 닉네임을 변경한다.") + @Test + void loadUser_WhenExistingUser_ShouldUpdateOAuth() { + // Given + User existingUser = User.of(3, LocalDateTime.now()); + + OAuth existingOAuth = new OAuth(); + existingOAuth.setUserId(3); + existingOAuth.setProvider(provider); + existingOAuth.setProviderUserId(providerId); + existingOAuth.setEmail("old@example.com"); + existingOAuth.setNickname("Old User"); + + UserOAuthDetails userOAuthDetails = UserOAuthDetails.of(existingUser, existingOAuth); + + when(userRepository.findUserByOAuthProviderAndProviderId(provider, providerId)).thenReturn(userOAuthDetails); + when(oAuthRepository.findByProviderAndProviderUserId(provider, providerId)).thenReturn(existingOAuth); + when(oAuthRepository.save(any(OAuth.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + OAuth2User result = customOAuth2UserService.loadUser(userRequest); + + // Then + assertNotNull(result); + verify(oAuthRepository).save(existingOAuth); + assertEquals("test@example.com", existingOAuth.getEmail()); + assertEquals("Test User", existingOAuth.getNickname()); + + verify(userRepository, never()).save(any(User.class)); + verify(accountService, never()).createNewAccount(anyInt(), anyLong()); + + assertInstanceOf(CustomOAuth2User.class, result); + assertEquals("Test User", result.getName()); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/login/application/CustomSuccessHandlerTest.java b/src/test/java/com/cleanengine/coin/user/login/application/CustomSuccessHandlerTest.java new file mode 100644 index 00000000..3fb5a2e0 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/login/application/CustomSuccessHandlerTest.java @@ -0,0 +1,60 @@ +package com.cleanengine.coin.user.login.application; + +import static org.mockito.Mockito.*; + +import com.cleanengine.coin.user.login.infra.CustomOAuth2User; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.security.core.Authentication; + +@DisplayName("인증 성공 핸들러 단위테스트") +@ExtendWith(MockitoExtension.class) +class CustomSuccessHandlerTest { + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private Authentication authentication; + + @Mock + private JWTUtil jwtUtil; + + @Mock + private CustomOAuth2User customOAuth2User; + + private CustomSuccessHandler customSuccessHandler; + + @BeforeEach + void setUp() { + customSuccessHandler = new CustomSuccessHandler(jwtUtil, true, "frontend URL"); + } + + @DisplayName("인증 성공 시 JWT 토큰을 쿠키에 저장하고 FE로 리디렉션한다.") + @Test + void whenAuthenticationSuccess_thenSetCookieAndRedirect() throws Exception { + // given + int userId = 1; + when(authentication.getPrincipal()).thenReturn(customOAuth2User); + when(customOAuth2User.getUserId()).thenReturn(userId); + when(jwtUtil.createJwt(eq(userId), anyLong())).thenReturn("test.jwt.token"); + + // when + customSuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + verify(response).addCookie(any(Cookie.class)); + verify(response).sendRedirect("frontend URL"); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/login/application/JWTFilterTest.java b/src/test/java/com/cleanengine/coin/user/login/application/JWTFilterTest.java new file mode 100644 index 00000000..008d18b7 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/login/application/JWTFilterTest.java @@ -0,0 +1,116 @@ +package com.cleanengine.coin.user.login.application; + +import static org.mockito.Mockito.*; + +import com.cleanengine.coin.common.response.ErrorStatus; +import com.cleanengine.coin.configuration.SecurityEndpoints.EndpointConfig; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.io.PrintWriter; +import java.io.StringWriter; + +@DisplayName("JWTFilter 단위테스트") +@ExtendWith(MockitoExtension.class) +class JWTFilterTest { + + @Mock + private JWTUtil jwtUtil; + @Mock + private EndpointConfig endpointConfig; + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + @Mock + private FilterChain filterChain; + + @InjectMocks + private JWTFilter jwtFilter; + + private final String publicPath = "/api/public"; + private final String privatePath = "/api/private"; + private final int unauthorizedStatus = ErrorStatus.UNAUTHORIZED_RESOURCE.getHttpStatus().value(); + + @DisplayName("public path 접근 시 인증없이 통과한다.") + @Test + void whenPublicPath_thenProceedWithoutValidation() throws Exception { + // given + when(request.getRequestURI()).thenReturn(publicPath); + when(endpointConfig.isPublicPath(publicPath)).thenReturn(true); + + // when + jwtFilter.doFilterInternal(request, response, filterChain); + + // then + verify(filterChain).doFilter(request, response); + verify(jwtUtil, never()).getUserId(any()); + } + + @DisplayName("private path 접근 시 토큰이 없으면 401 응답을 반환한다.") + @Test + void whenNoToken_thenReturnUnauthorized() throws Exception { + // given + when(request.getRequestURI()).thenReturn("/api/private"); + when(endpointConfig.isPublicPath("/api/private")).thenReturn(false); + when(request.getCookies()).thenReturn(null); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + when(response.getWriter()).thenReturn(writer); + + // when + jwtFilter.doFilterInternal(request, response, filterChain); + + // then + verify(response).setStatus(unauthorizedStatus); + verify(filterChain, never()).doFilter(request, response); + } + + @DisplayName("private path 접근 시 유효한 토큰인 경우 다음 필터체인으로 넘어간다.") + @Test + void whenValidToken_thenAuthenticate() throws Exception { + // given + when(request.getRequestURI()).thenReturn(privatePath); + when(endpointConfig.isPublicPath(privatePath)).thenReturn(false); + Cookie cookie = new Cookie("access_token", "valid_token"); + when(request.getCookies()).thenReturn(new Cookie[]{cookie}); + when(jwtUtil.getUserId("valid_token")).thenReturn(1); + when(jwtUtil.isExpired("valid_token")).thenReturn(false); + + // when + jwtFilter.doFilterInternal(request, response, filterChain); + + // then + verify(filterChain).doFilter(request, response); + } + + @DisplayName("private path 접근 시 만료된 토큰인 경우 401 응답을 반환한다.") + @Test + void whenExpiredToken_thenReturnUnauthorized() throws Exception { + // given + when(request.getRequestURI()).thenReturn(privatePath); + when(endpointConfig.isPublicPath(privatePath)).thenReturn(false); + Cookie cookie = new Cookie("access_token", "expired_token"); + when(request.getCookies()).thenReturn(new Cookie[]{cookie}); + when(jwtUtil.getUserId("expired_token")).thenReturn(1); + when(jwtUtil.isExpired("expired_token")).thenReturn(true); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + when(response.getWriter()).thenReturn(writer); + + // when + jwtFilter.doFilterInternal(request, response, filterChain); + + // then + verify(response).setStatus(unauthorizedStatus); + verify(filterChain, never()).doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/login/presentation/LoginControllerTest.java b/src/test/java/com/cleanengine/coin/user/login/presentation/LoginControllerTest.java new file mode 100644 index 00000000..e5f297d4 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/login/presentation/LoginControllerTest.java @@ -0,0 +1,103 @@ +package com.cleanengine.coin.user.login.presentation; + +import com.cleanengine.coin.user.login.application.JWTUtil; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("로그인 및 토큰 API 통합테스트") +@AutoConfigureMockMvc +@SpringBootTest +class LoginControllerTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private JWTUtil jwtUtil; + + @Test + @DisplayName("헬스체크 성공 시 성공 응답 반환") + void healthcheckTest() throws Exception { + // when + ResultActions resultActions = mvc.perform(get("/api/healthcheck") + .accept(MediaType.APPLICATION_JSON)); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data").value("Health Check Completed")); + } + + @Test + @DisplayName("유효한 JWT 토큰과 함께 요청 시 토큰 검증에 성공한다.") + void validTokenCheckTest() throws Exception { + // given + Integer userId = 123; + Long expiredMs = 1000L * 60 * 60; + String token = jwtUtil.createJwt(userId, expiredMs); + Cookie cookie = new Cookie("access_token", token); + cookie.setPath("/"); + + // when + ResultActions resultActions = mvc.perform(get("/api/tokencheck") + .cookie(cookie) + .accept(MediaType.APPLICATION_JSON)); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.message").value("Token is Valid")) + .andExpect(jsonPath("$.data.userId").value(userId)); + } + + @Test + @DisplayName("유효하지 않은 JWT 토큰으로 요청 시 토큰 검증에 실패한다.") + void invalidTokenCheckTest() throws Exception { + // given + String invalidToken = "invalid.jwt.token"; + Cookie cookie = new Cookie("access_token", invalidToken); + cookie.setPath("/"); + + // when + ResultActions resultActions = mvc.perform(get("/api/tokencheck") + .cookie(cookie) + .accept(MediaType.APPLICATION_JSON)); + + // then + resultActions.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.error").exists()); + } + + @Test + @DisplayName("로그아웃 시 access_token 쿠키가 제거된다") + void logoutTest() throws Exception { + // given + Integer userId = 123; + Long expiredMs = 1000L * 60 * 60; + String token = jwtUtil.createJwt(userId, expiredMs); + Cookie cookie = new Cookie("access_token", token); + cookie.setPath("/"); + + // when + ResultActions resultActions = mvc.perform(get("/api/logout") + .cookie(cookie) + .accept(MediaType.APPLICATION_JSON)); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(cookie().maxAge("access_token", 0)) + .andExpect(cookie().value("access_token", org.hamcrest.Matchers.emptyOrNullString())); + } +} \ No newline at end of file