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