From 6da93bb52dd435d537f0dc5e43991c2e7973d873 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Tue, 16 Jun 2026 14:07:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20SyncOrderProcessor=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동기 체결 경로에서만 쓰이던 SyncOrderProcessor, CreateOrderResponse 삭제. OrderService에서 동기 처리 로직도 함께 정리. --- .../order/dto/CreateOrderResponse.java | 67 -------- .../coinflow/order/service/OrderService.java | 42 +---- .../service/processor/SyncOrderProcessor.java | 155 ------------------ 3 files changed, 1 insertion(+), 263 deletions(-) delete mode 100644 src/main/java/com/coinflow/order/dto/CreateOrderResponse.java delete mode 100644 src/main/java/com/coinflow/order/service/processor/SyncOrderProcessor.java diff --git a/src/main/java/com/coinflow/order/dto/CreateOrderResponse.java b/src/main/java/com/coinflow/order/dto/CreateOrderResponse.java deleted file mode 100644 index f0c3567..0000000 --- a/src/main/java/com/coinflow/order/dto/CreateOrderResponse.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.coinflow.order.dto; - -import com.coinflow.order.domain.Order; -import com.coinflow.trade.domain.Trade; - -import java.time.LocalDateTime; -import java.util.List; - -public record CreateOrderResponse( - Long orderId, - String clientOrderId, - String market, - String side, - String type, - String timeInForce, - String price, - String originalQuantity, - String executedQuantity, - String remainingQuantity, - String executedQuoteAmount, - String lockedAsset, - String lockedAmount, - String status, - LocalDateTime createdAt, - List trades -) { - public static CreateOrderResponse of(Order order, List trades) { - List tradeResults = trades.stream() - .map(t -> new TradeResult( - t.getId(), - t.getPrice().toPlainString(), - t.getQuantity().toPlainString(), - t.getQuoteAmount().toPlainString(), - t.getMakerOrderId().equals(order.getId()) ? "MAKER" : "TAKER", - t.getTradedAt() - )) - .toList(); - return new CreateOrderResponse( - order.getId(), - order.getClientOrderId(), - order.getMarketSymbol(), - order.getSide().name(), - order.getType().name(), - order.getTimeInForce().name(), - order.getPrice().toPlainString(), - order.getOriginalQuantity().toPlainString(), - order.getExecutedQuantity().toPlainString(), - order.getRemainingQuantity().toPlainString(), - order.getExecutedQuoteAmount().toPlainString(), - order.getLockedAsset(), - order.getLockedAmount().toPlainString(), - order.getStatus().name(), - order.getCreatedAt(), - tradeResults - ); - } - - public record TradeResult( - Long tradeId, - String price, - String quantity, - String quoteAmount, - String liquidity, - LocalDateTime tradedAt - ) { - } -} diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index 58a406b..e563f32 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -1,23 +1,12 @@ package com.coinflow.order.service; -import com.coinflow.common.exception.ApiException; -import com.coinflow.common.exception.ErrorCode; -import com.coinflow.market.domain.Market; -import com.coinflow.market.repository.MarketRepository; -import com.coinflow.order.domain.OrderSide; import com.coinflow.order.dto.AcceptedOrderResponse; import com.coinflow.order.dto.CancelOrderResponse; import com.coinflow.order.dto.CreateOrderRequest; -import com.coinflow.order.dto.CreateOrderResponse; import com.coinflow.order.dto.OrderDetailResponse; import com.coinflow.order.dto.OrderSummaryResponse; import com.coinflow.order.service.cancel.OrderCancelService; -import com.coinflow.order.service.command.CreateOrderCommand; -import com.coinflow.order.service.command.MarketOrderCommandQueue; -import com.coinflow.order.service.processor.SyncOrderProcessor; import com.coinflow.order.service.query.OrderQueryService; -import com.coinflow.order.service.support.ClientOrderIdService; -import com.coinflow.order.service.support.OrderCreateValidator; import org.springframework.stereotype.Service; import java.util.List; @@ -25,50 +14,21 @@ @Service public class OrderService { - private final MarketRepository marketRepository; - private final OrderCreateValidator orderCreateValidator; - private final ClientOrderIdService clientOrderIdService; - private final MarketOrderCommandQueue marketOrderCommandQueue; - private final SyncOrderProcessor syncOrderProcessor; private final AcceptedOrderService acceptedOrderService; private final OrderCancelService orderCancelService; private final OrderQueryService orderQueryService; public OrderService( - MarketRepository marketRepository, - OrderCreateValidator orderCreateValidator, - ClientOrderIdService clientOrderIdService, - MarketOrderCommandQueue marketOrderCommandQueue, - SyncOrderProcessor syncOrderProcessor, AcceptedOrderService acceptedOrderService, OrderCancelService orderCancelService, OrderQueryService orderQueryService ) { - this.marketRepository = marketRepository; - this.orderCreateValidator = orderCreateValidator; - this.clientOrderIdService = clientOrderIdService; - this.marketOrderCommandQueue = marketOrderCommandQueue; - this.syncOrderProcessor = syncOrderProcessor; this.acceptedOrderService = acceptedOrderService; this.orderCancelService = orderCancelService; this.orderQueryService = orderQueryService; } - public CreateOrderResponse createOrder(Long currentUserId, CreateOrderRequest request) { - Market market = marketRepository.findBySymbol(request.market()) - .orElseThrow(() -> new ApiException(ErrorCode.MARKET_NOT_FOUND)); - CreateOrderCommand command = orderCreateValidator.validate(market, request); - OrderSide side = command.side(); - clientOrderIdService.validateUnique(currentUserId, request, market, side); - - return marketOrderCommandQueue.submit( - market, - side, - () -> syncOrderProcessor.process(currentUserId, request, market, command) - ); - } - - public AcceptedOrderResponse acceptOrder(Long currentUserId, CreateOrderRequest request) { + public AcceptedOrderResponse createOrder(Long currentUserId, CreateOrderRequest request) { return acceptedOrderService.acceptOrder(currentUserId, request); } diff --git a/src/main/java/com/coinflow/order/service/processor/SyncOrderProcessor.java b/src/main/java/com/coinflow/order/service/processor/SyncOrderProcessor.java deleted file mode 100644 index 7d665f8..0000000 --- a/src/main/java/com/coinflow/order/service/processor/SyncOrderProcessor.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.coinflow.order.service.processor; - -import com.coinflow.common.exception.ApiException; -import com.coinflow.common.exception.ErrorCode; -import com.coinflow.event.service.DomainEventRecorder; -import com.coinflow.market.domain.Market; -import com.coinflow.order.domain.Order; -import com.coinflow.order.dto.CreateOrderRequest; -import com.coinflow.order.dto.CreateOrderResponse; -import com.coinflow.order.matching.MatchResult; -import com.coinflow.order.matching.MatchingEngine; -import com.coinflow.order.matching.OrderBookRecoveryService; -import com.coinflow.order.repository.OrderRepository; -import com.coinflow.order.service.command.CreateOrderCommand; -import com.coinflow.order.service.lock.OrderAssetLockService; -import com.coinflow.order.service.settlement.OrderSettlementService; -import com.coinflow.order.service.support.ClientOrderIdService; -import com.coinflow.order.service.support.MarketSequenceAllocator; -import com.coinflow.trade.domain.Trade; -import com.coinflow.wallet.domain.Wallet; -import com.coinflow.wallet.domain.WalletLedger; -import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; -import org.springframework.transaction.support.TransactionTemplate; - -import java.util.ArrayList; -import java.util.List; - -@Slf4j -@Service -public class SyncOrderProcessor { - - private final OrderRepository orderRepository; - private final MatchingEngine matchingEngine; - private final OrderBookRecoveryService orderBookRecoveryService; - private final DomainEventRecorder eventRecorder; - private final OrderAssetLockService orderAssetLockService; - private final OrderSettlementService orderSettlementService; - private final MarketSequenceAllocator marketSequenceAllocator; - private final ClientOrderIdService clientOrderIdService; - private final TransactionTemplate transactionTemplate; - - public SyncOrderProcessor( - OrderRepository orderRepository, - MatchingEngine matchingEngine, - OrderBookRecoveryService orderBookRecoveryService, - DomainEventRecorder eventRecorder, - OrderAssetLockService orderAssetLockService, - OrderSettlementService orderSettlementService, - MarketSequenceAllocator marketSequenceAllocator, - ClientOrderIdService clientOrderIdService, - PlatformTransactionManager transactionManager - ) { - this.orderRepository = orderRepository; - this.matchingEngine = matchingEngine; - this.orderBookRecoveryService = orderBookRecoveryService; - this.eventRecorder = eventRecorder; - this.orderAssetLockService = orderAssetLockService; - this.orderSettlementService = orderSettlementService; - this.marketSequenceAllocator = marketSequenceAllocator; - this.clientOrderIdService = clientOrderIdService; - this.transactionTemplate = new TransactionTemplate(transactionManager); - } - - public CreateOrderResponse process( - Long currentUserId, - CreateOrderRequest request, - Market market, - CreateOrderCommand command - ) { - try { - return transactionTemplate.execute(status -> - executeInTransaction(currentUserId, request, market, command)); - } catch (DataIntegrityViolationException e) { - if (request.clientOrderId() != null && clientOrderIdService.isDuplicateConstraintViolation(e)) { - throw new ApiException(ErrorCode.DUPLICATE_CLIENT_ORDER_ID); - } - throw e; - } - } - - private CreateOrderResponse executeInTransaction( - Long currentUserId, - CreateOrderRequest request, - Market market, - CreateOrderCommand command - ) { - Long sequence = marketSequenceAllocator.nextSequence(market.getId()); - Wallet wallet = orderAssetLockService.lockTakerWallet(currentUserId, command); - Order order = createAndSaveOrder(currentUserId, request, market, command, sequence); - WalletLedger orderLockLedger = orderAssetLockService.createOrderLockLedger(wallet, order, command); - - List plan = matchingEngine.planMatchRejectingSelfTrade(market, order); - List autoCanceledMakers = new ArrayList<>(); - List trades = orderSettlementService.settle(market, order, plan, autoCanceledMakers, orderLockLedger); - if (trades.isEmpty()) { - recordAcceptedOrderWithoutTrade(order, orderLockLedger, command); - } - - registerOrderBookSynchronization(market, order, plan, autoCanceledMakers); - return CreateOrderResponse.of(order, trades); - } - - private Order createAndSaveOrder( - Long currentUserId, - CreateOrderRequest request, - Market market, - CreateOrderCommand command, - Long sequence - ) { - Order order = Order.create( - currentUserId, market.getId(), market.getSymbol(), - command.side(), command.type(), command.timeInForce(), - command.price(), command.quantity(), - command.lockedAsset(), command.lockedAmount(), - sequence, request.clientOrderId() - ); - orderRepository.save(order); - return order; - } - - private void recordAcceptedOrderWithoutTrade( - Order order, - WalletLedger orderLockLedger, - CreateOrderCommand command - ) { - eventRecorder.recordOrderAccepted(order); - orderAssetLockService.saveOrderLockLedger(orderLockLedger, command); - } - - private void registerOrderBookSynchronization( - Market market, - Order order, - List plan, - List autoCanceledMakers - ) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - try { - matchingEngine.applyMatchPlan(market, order, plan); - autoCanceledMakers.forEach(canceledMaker -> - matchingEngine.cancelOrder(market.getSymbol(), canceledMaker)); - } catch (Exception e) { - log.error("오더북 applyMatchPlan 실패: orderId={}, DB 체결 내역 기반 재빌드 시도", order.getId(), e); - orderBookRecoveryService.rebuildAfterApplyFailure(market.getId()); - } - } - }); - } -} From 88499d5d10bf9cb611953151cf2f49eb0c4acf0e Mon Sep 17 00:00:00 2001 From: ohhalim Date: Tue, 16 Jun 2026 14:08:05 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20POST=20/orders=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /async 엔드포인트 제거하고 / 로 일원화. 이제 모든 주문 생성은 202 Accepted 반환. --- .../coinflow/order/api/OrderController.java | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/coinflow/order/api/OrderController.java b/src/main/java/com/coinflow/order/api/OrderController.java index 2647085..ecb5dba 100644 --- a/src/main/java/com/coinflow/order/api/OrderController.java +++ b/src/main/java/com/coinflow/order/api/OrderController.java @@ -1,9 +1,8 @@ package com.coinflow.order.api; -import com.coinflow.order.dto.CancelOrderResponse; import com.coinflow.order.dto.AcceptedOrderResponse; +import com.coinflow.order.dto.CancelOrderResponse; import com.coinflow.order.dto.CreateOrderRequest; -import com.coinflow.order.dto.CreateOrderResponse; import com.coinflow.order.dto.OrderDetailResponse; import com.coinflow.order.dto.OrderSummaryResponse; import com.coinflow.order.service.OrderService; @@ -22,7 +21,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -36,23 +34,13 @@ public class OrderController { private final OrderService orderService; @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public CreateOrderResponse createOrder( - @AuthenticationPrincipal Jwt jwt, - @Valid @RequestBody CreateOrderRequest request - ) { - Long userId = Long.parseLong(jwt.getSubject()); - return orderService.createOrder(userId, request); - } - - @PostMapping("/async") - public ResponseEntity acceptOrder( + public ResponseEntity createOrder( @AuthenticationPrincipal Jwt jwt, @Valid @RequestBody CreateOrderRequest request ) { Long userId = Long.parseLong(jwt.getSubject()); return ResponseEntity.status(HttpStatus.ACCEPTED) - .body(orderService.acceptOrder(userId, request)); + .body(orderService.createOrder(userId, request)); } @PostMapping("/{id}/cancel") From 7e248e6fb3a55ea01841718f4dd6d800ef527099 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Tue, 16 Jun 2026 14:08:13 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=98=A4=EB=8D=94=EB=B6=81=20?= =?UTF-8?q?=EA=B0=80=EA=B2=A9=20=ED=91=9C=EC=8B=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB에서 읽어온 BigDecimal이 scale-18로 들어오면서 100000000.000000000000000000 형태로 노출되던 문제 수정. stripTrailingZeros().toPlainString() 적용. --- src/main/java/com/coinflow/market/dto/OrderBookResponse.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/coinflow/market/dto/OrderBookResponse.java b/src/main/java/com/coinflow/market/dto/OrderBookResponse.java index ac16044..fdfd38b 100644 --- a/src/main/java/com/coinflow/market/dto/OrderBookResponse.java +++ b/src/main/java/com/coinflow/market/dto/OrderBookResponse.java @@ -40,8 +40,8 @@ private static List aggregate( return quantitiesByPrice.entrySet().stream() .limit(depth) .map(entry -> new PriceLevel( - entry.getKey().toPlainString(), - entry.getValue().toPlainString() + entry.getKey().stripTrailingZeros().toPlainString(), + entry.getValue().stripTrailingZeros().toPlainString() )) .toList(); } From 81b455b74b7b174c54c4448b0a089b3a5401d8dc Mon Sep 17 00:00:00 2001 From: ohhalim Date: Tue, 16 Jun 2026 14:08:22 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=9B=8C=EC=BB=A4=20=EC=99=84=EB=A3=8C=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동기→비동기 전환 이후 워커 처리 완료 전에 assertion 하면서 터지던 테스트 수정. Awaitility로 각 테스트에서 워커 완료 대기 추가. --- .../ConcurrencyIntegrationTest.java | 75 ++++++++++++---- .../coinflow/integration/DomainEventTest.java | 17 +++- .../KafkaPublishingIntegrationTest.java | 4 + .../integration/MatchingSettlementTest.java | 88 ++++++++++++------- .../integration/OrderBookRecoveryTest.java | 27 ++++-- ...cketOrderBookBroadcastIntegrationTest.java | 9 ++ .../integration/WebSocketStompE2eTest.java | 5 ++ .../WebSocketTradeFeedIntegrationTest.java | 4 + .../java/com/coinflow/order/OrderApiTest.java | 81 +++++++++++------ .../java/com/coinflow/query/QueryApiTest.java | 28 ++++++ .../com/coinflow/wallet/WalletApiTest.java | 6 ++ 11 files changed, 257 insertions(+), 87 deletions(-) diff --git a/src/test/java/com/coinflow/integration/ConcurrencyIntegrationTest.java b/src/test/java/com/coinflow/integration/ConcurrencyIntegrationTest.java index 3619373..06d05b6 100644 --- a/src/test/java/com/coinflow/integration/ConcurrencyIntegrationTest.java +++ b/src/test/java/com/coinflow/integration/ConcurrencyIntegrationTest.java @@ -25,6 +25,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import com.coinflow.order.domain.OrderStatus; import java.math.BigDecimal; import java.time.Duration; import java.util.ArrayList; @@ -39,6 +40,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SuppressWarnings({"rawtypes", "unchecked"}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -106,7 +108,7 @@ void assertIntegrity() { ); long successCount = responses.stream() - .filter(response -> response.getStatusCode() == HttpStatus.CREATED) + .filter(response -> response.getStatusCode() == HttpStatus.ACCEPTED) .count(); long insufficientBalanceCount = responses.stream() .filter(response -> response.getStatusCode() == HttpStatus.BAD_REQUEST) @@ -131,6 +133,11 @@ void assertIntegrity() { .filter(ledger -> ledger.getType() == LedgerType.ORDER_LOCK) .count(); assertThat(orderLockLedgerCount).isEqualTo(successCount); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findAll().stream() + .filter(o -> o.getStatus() == OrderStatus.ACCEPTED) + .count()).isZero()); } @RepeatedTest(10) @@ -149,9 +156,14 @@ void assertIntegrity() { "0.5", "con002-maker" ); - assertThat(makerResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(makerResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); Long makerOrderId = ((Number) makerResponse.getBody().get("orderId")).longValue(); + // 매도 주문이 오더북에 적재될 때까지 대기 + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(makerOrderId).orElseThrow().getStatus().name()).isEqualTo("OPEN") + ); + List buyerTokens = new ArrayList<>(); for (int i = 0; i < TAKER_REQUESTS; i++) { String buyerEmail = "con002-buyer-" + i + "@example.com"; @@ -174,13 +186,24 @@ void assertIntegrity() { assertThat(responses) .extracting(ResponseEntity::getStatusCode) - .containsOnly(HttpStatus.CREATED); - - long filledTakerCount = responses.stream() - .filter(response -> "FILLED".equals(response.getBody().get("status"))) + .containsOnly(HttpStatus.ACCEPTED); + + // 모든 taker 주문이 최종 상태로 전이될 때까지 대기 + List takerOrderIds = responses.stream() + .map(r -> ((Number) r.getBody().get("orderId")).longValue()) + .toList(); + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + long pendingCount = takerOrderIds.stream() + .filter(id -> orderRepository.findById(id).orElseThrow().getStatus() == OrderStatus.ACCEPTED) + .count(); + assertThat(pendingCount).isZero(); + }); + + long filledTakerCount = takerOrderIds.stream() + .filter(id -> orderRepository.findById(id).orElseThrow().getStatus() == OrderStatus.FILLED) .count(); - long openTakerCount = responses.stream() - .filter(response -> "OPEN".equals(response.getBody().get("status"))) + long openTakerCount = takerOrderIds.stream() + .filter(id -> orderRepository.findById(id).orElseThrow().getStatus() == OrderStatus.OPEN) .count(); assertThat(filledTakerCount).isEqualTo(5); @@ -283,7 +306,7 @@ void assertIntegrity() { assertThat(writeResponses) .hasSize(ORDERBOOK_WRITERS * ORDERBOOK_WRITES_PER_WRITER) .extracting(ResponseEntity::getStatusCode) - .containsOnly(HttpStatus.CREATED); + .containsOnly(HttpStatus.ACCEPTED); assertThat(readResponses) .hasSize(ORDERBOOK_READERS * ORDERBOOK_READS_PER_READER) .extracting(ResponseEntity::getStatusCode) @@ -297,7 +320,9 @@ void assertIntegrity() { } assertThat(orderRepository.count()).isEqualTo(ORDERBOOK_WRITERS * ORDERBOOK_WRITES_PER_WRITER); - assertThat(matchingEngine.getBuySide("BTC-KRW")).hasSize(ORDERBOOK_WRITERS * ORDERBOOK_WRITES_PER_WRITER); + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> + assertThat(matchingEngine.getBuySide("BTC-KRW")).hasSize(ORDERBOOK_WRITERS * ORDERBOOK_WRITES_PER_WRITER) + ); } finally { executor.shutdownNow(); assertThat(executor.awaitTermination(Duration.ofSeconds(5).toMillis(), TimeUnit.MILLISECONDS)).isTrue(); @@ -324,9 +349,14 @@ void assertIntegrity() { "0.5", "con004-maker" ); - assertThat(makerResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(makerResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); Long makerOrderId = ((Number) makerResponse.getBody().get("orderId")).longValue(); + // 매수 주문이 오더북에 적재될 때까지 대기 + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(makerOrderId).orElseThrow().getStatus().name()).isEqualTo("OPEN") + ); + ExecutorService executor = Executors.newFixedThreadPool(2); CountDownLatch ready = new CountDownLatch(2); CountDownLatch start = new CountDownLatch(1); @@ -376,11 +406,18 @@ void assertIntegrity() { assertWalletNeverNegative(sellerKrw); assertWalletNeverNegative(sellerBtc); + // taker 주문 최종 상태 대기 + Long takerOrderId = ((Number) takerResponse.getBody().get("orderId")).longValue(); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(takerOrderId).orElseThrow().getStatus().name()) + .isIn("OPEN", "FILLED") + ); + if ("CANCELED".equals(makerOrder.getStatus().name())) { assertThat(cancelResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(cancelResponse.getBody().get("status")).isEqualTo("CANCELED"); - assertThat(takerResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(takerResponse.getBody().get("status")).isEqualTo("OPEN"); + assertThat(takerResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(orderRepository.findById(takerOrderId).orElseThrow().getStatus().name()).isEqualTo("OPEN"); assertThat(tradeRepository.count()).isZero(); assertThat(buyerKrw.getAvailableBalance()).isEqualByComparingTo("50000"); @@ -392,8 +429,8 @@ void assertIntegrity() { } else { assertThat(cancelResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(cancelResponse.getBody().get("code")).isEqualTo("ORDER_NOT_CANCELABLE"); - assertThat(takerResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(takerResponse.getBody().get("status")).isEqualTo("FILLED"); + assertThat(takerResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(orderRepository.findById(takerOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED"); assertThat(tradeRepository.count()).isEqualTo(1); BigDecimal totalTradedQuantity = tradeRepository.findAll().stream() @@ -435,7 +472,7 @@ void assertIntegrity() { ); long successCount = responses.stream() - .filter(response -> response.getStatusCode() == HttpStatus.CREATED) + .filter(response -> response.getStatusCode() == HttpStatus.ACCEPTED) .count(); long duplicateCount = responses.stream() .filter(response -> response.getStatusCode() == HttpStatus.CONFLICT) @@ -456,7 +493,9 @@ void assertIntegrity() { .filter(ledger -> ledger.getType() == LedgerType.ORDER_LOCK) .count(); assertThat(orderLockLedgerCount).isEqualTo(1); - assertThat(matchingEngine.getBuySide("BTC-KRW")).hasSize(1); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(matchingEngine.getBuySide("BTC-KRW")).hasSize(1) + ); } @RepeatedTest(10) @@ -475,7 +514,7 @@ void assertIntegrity() { "0.0001", "con006-maker" ); - assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); Long orderId = ((Number) createResponse.getBody().get("orderId")).longValue(); List> responses = runConcurrently(10, index -> cancelOrder(token, orderId)); diff --git a/src/test/java/com/coinflow/integration/DomainEventTest.java b/src/test/java/com/coinflow/integration/DomainEventTest.java index d54904f..8e6f4d7 100644 --- a/src/test/java/com/coinflow/integration/DomainEventTest.java +++ b/src/test/java/com/coinflow/integration/DomainEventTest.java @@ -21,11 +21,15 @@ import org.springframework.context.annotation.Import; import org.springframework.http.*; +import com.coinflow.order.domain.OrderStatus; + import java.math.BigDecimal; +import java.time.Duration; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SuppressWarnings("rawtypes") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -93,6 +97,11 @@ void assertIntegrity() { var sellResponse = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); Long sellOrderId = ((Number) sellResponse.getBody().get("orderId")).longValue(); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(orderRepository.findById(buyOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.FILLED); + assertThat(orderRepository.findById(sellOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.FILLED); + }); + // 매수 주문: ORDER_ACCEPTED + ORDER_FILLED var buyEvents = domainEventRepository.findAllByAggregateTypeAndAggregateId("ORDER", buyOrderId); var buyEventTypes = buyEvents.stream().map(e -> e.getEventType()).toList(); @@ -110,8 +119,7 @@ void assertIntegrity() { ); // TRADE: TRADE_CREATED + SETTLEMENT_COMPLETED - var trades = (List) sellResponse.getBody().get("trades"); - Long tradeId = ((Number) ((Map) trades.get(0)).get("tradeId")).longValue(); + Long tradeId = tradeRepository.findAll().get(0).getId(); var tradeEvents = domainEventRepository.findAllByAggregateTypeAndAggregateId("TRADE", tradeId); var tradeEventTypes = tradeEvents.stream().map(e -> e.getEventType()).toList(); assertThat(tradeEventTypes).containsExactlyInAnyOrder( @@ -133,6 +141,11 @@ void assertIntegrity() { createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(domainEventRepository.findAllByAggregateTypeAndAggregateId("ORDER", buyOrderId) + .stream().map(e -> e.getEventType()).toList()) + .contains(DomainEventType.ORDER_PARTIALLY_FILLED)); + var buyEvents = domainEventRepository.findAllByAggregateTypeAndAggregateId("ORDER", buyOrderId); var buyEventTypes = buyEvents.stream().map(e -> e.getEventType()).toList(); diff --git a/src/test/java/com/coinflow/integration/KafkaPublishingIntegrationTest.java b/src/test/java/com/coinflow/integration/KafkaPublishingIntegrationTest.java index 00497fb..d1a97f1 100644 --- a/src/test/java/com/coinflow/integration/KafkaPublishingIntegrationTest.java +++ b/src/test/java/com/coinflow/integration/KafkaPublishingIntegrationTest.java @@ -39,6 +39,7 @@ import java.util.stream.StreamSupport; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SuppressWarnings("rawtypes") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -113,6 +114,9 @@ void tearDown() { createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(tradeRepository.count()).isEqualTo(1)); + assertThat(domainEventRepository.findAllByPublishedFalseOrderByIdAsc()).isNotEmpty(); int publishedCount = outboxPublisher.publishPendingEvents(); diff --git a/src/test/java/com/coinflow/integration/MatchingSettlementTest.java b/src/test/java/com/coinflow/integration/MatchingSettlementTest.java index 91528ca..03feb37 100644 --- a/src/test/java/com/coinflow/integration/MatchingSettlementTest.java +++ b/src/test/java/com/coinflow/integration/MatchingSettlementTest.java @@ -21,10 +21,12 @@ import org.springframework.http.*; import java.math.BigDecimal; +import java.time.Duration; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SuppressWarnings("rawtypes") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -80,9 +82,12 @@ void assertIntegrity() { // taker: BUY at 100,000,000 → locks 10,000 KRW, matches at 9,800 KRW var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); - assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(buyResponse.getBody().get("status")).isEqualTo("FILLED"); + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(buyOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED") + ); var buyer = userRepository.findByEmail("set001-buyer@example.com").orElseThrow(); var seller = userRepository.findByEmail("set001-seller@example.com").orElseThrow(); @@ -128,12 +133,13 @@ void assertIntegrity() { // taker BUY 0.0002 → fully filled across two makers var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0002", null); + Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); - assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(buyResponse.getBody().get("status")).isEqualTo("FILLED"); - - var trades = (List) buyResponse.getBody().get("trades"); - assertThat(trades).hasSize(2); + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(orderRepository.findById(buyOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED"); + assertThat(tradeRepository.count()).isEqualTo(2); + }); var buyer = userRepository.findByEmail("set001b-buyer@example.com").orElseThrow(); var buyerKrw = findWallet(buyer.getId(), "KRW"); @@ -159,9 +165,12 @@ void assertIntegrity() { // taker: SELL → 전량 체결 var sellResponse = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + Long sellOrderId = ((Number) sellResponse.getBody().get("orderId")).longValue(); - assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(sellResponse.getBody().get("status")).isEqualTo("FILLED"); + assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(sellOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED") + ); var buyer = userRepository.findByEmail("set002-buyer@example.com").orElseThrow(); var seller = userRepository.findByEmail("set002-seller@example.com").orElseThrow(); @@ -198,13 +207,16 @@ void assertIntegrity() { // user1 SELL at 100,000,000 (자기 주문) createOrder(user1Token, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); - // user1 BUY at 100,000,000 → user1 자신의 SELL과 가격 교차 → 거절 + // user1 BUY at 100,000,000 → user1 자신의 SELL과 가격 교차 → 비동기 워커에서 거절 var buyResponse = createOrder(user1Token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); - assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(buyResponse.getBody().get("code")).isEqualTo("SELF_TRADE_NOT_ALLOWED"); + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(buyOrderId).orElseThrow().getStatus().name()).isEqualTo("REJECTED") + ); - // user1 KRW 잔고 변동 없음 (주문 거절 → lock 없음) + // user1 KRW 잔고 변동 없음 (주문 거절 → lock 해제됨) var user1 = userRepository.findByEmail("set005-user1@example.com").orElseThrow(); var user1Krw = findWallet(user1.getId(), "KRW"); assertThat(user1Krw.getLockedBalance()).isEqualByComparingTo("0"); @@ -228,7 +240,10 @@ void assertIntegrity() { // taker: BUY 0.0002 → 0.0001만 체결, 나머지 OPEN var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0002", null); Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); - assertThat(buyResponse.getBody().get("status")).isEqualTo("PARTIALLY_FILLED"); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(buyOrderId).orElseThrow().getStatus().name()).isEqualTo("PARTIALLY_FILLED") + ); var buyer = userRepository.findByEmail("can002-buyer@example.com").orElseThrow(); var buyerKrw = findWallet(buyer.getId(), "KRW"); @@ -268,14 +283,13 @@ void assertIntegrity() { // BUY at 100,000,000 → seller3(98,000,000)과 체결 var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); - assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(buyResponse.getBody().get("status")).isEqualTo("FILLED"); - - var trades = (List) buyResponse.getBody().get("trades"); - assertThat(trades).hasSize(1); - var trade = (Map) trades.get(0); - assertThat(trade.get("price")).isEqualTo("98000000"); // 최저가 체결 + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(orderRepository.findById(buyOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED"); + assertThat(tradeRepository.count()).isEqualTo(1); + }); // buyer: 9,800 KRW 지출, 200 환불, 0.0001 BTC 수령 var buyer = userRepository.findByEmail("mat001-buyer@example.com").orElseThrow(); @@ -312,11 +326,13 @@ void assertIntegrity() { // buyer1: BUY 1.0 BTC at 9999 → partial fill, seller 잔여 0.0001 BTC var buyer1Response = createOrder(buyer1Token, "BTC-KRW", "BUY", "LIMIT", "GTC", "9999", "1.0000", null); - assertThat(buyer1Response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(buyer1Response.getBody().get("status")).isEqualTo("FILLED"); + Long buyer1OrderId = ((Number) buyer1Response.getBody().get("orderId")).longValue(); - var sellerOrder = orderRepository.findById(sellOrderId).orElseThrow(); - assertThat(sellerOrder.getStatus().name()).isEqualTo("CANCELED"); + assertThat(buyer1Response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(orderRepository.findById(buyer1OrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED"); + assertThat(orderRepository.findById(sellOrderId).orElseThrow().getStatus().name()).isEqualTo("CANCELED"); + }); var seller = userRepository.findByEmail("zq001-seller@example.com").orElseThrow(); assertThat(findWallet(seller.getId(), "BTC").getAvailableBalance()) @@ -329,8 +345,12 @@ void assertIntegrity() { // buyer2: 남은 SELL이 없으므로 OPEN 등록, 신규 체결 없음 var buyer2Response = createOrder(buyer2Token, "BTC-KRW", "BUY", "LIMIT", "GTC", "9999", "10", null); - assertThat(buyer2Response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(buyer2Response.getBody().get("status")).isEqualTo("OPEN"); + Long buyer2OrderId = ((Number) buyer2Response.getBody().get("orderId")).longValue(); + + assertThat(buyer2Response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(buyer2OrderId).orElseThrow().getStatus().name()).isEqualTo("OPEN") + ); // buyer2와 신규 체결 없음 assertThat(tradeRepository.count()).isEqualTo(tradeCountBefore); @@ -342,11 +362,19 @@ void assertIntegrity() { void INVARIANT_체결_후_모든_지갑_잔고_음수_불가() { String buyerToken = signupAndLogin("inv001-buyer@example.com"); String sellerToken = signupAndLogin("inv001-seller@example.com"); - depositKrw("inv001-buyer@example.com", new BigDecimal("98000")); + // BUY lock = 100,000,000 × 0.001 = 100,000 KRW + depositKrw("inv001-buyer@example.com", new BigDecimal("100000")); depositBtc("inv001-seller@example.com", new BigDecimal("0.01")); - createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "98000000", "0.001", null); - createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.001", null); + var sellResp = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "98000000", "0.001", null); + Long invSellId = ((Number) sellResp.getBody().get("orderId")).longValue(); + var buyResp = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.001", null); + Long invBuyId = ((Number) buyResp.getBody().get("orderId")).longValue(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(orderRepository.findById(invSellId).orElseThrow().getStatus().name()).isNotEqualTo("ACCEPTED"); + assertThat(orderRepository.findById(invBuyId).orElseThrow().getStatus().name()).isNotEqualTo("ACCEPTED"); + }); var buyer = userRepository.findByEmail("inv001-buyer@example.com").orElseThrow(); var seller = userRepository.findByEmail("inv001-seller@example.com").orElseThrow(); diff --git a/src/test/java/com/coinflow/integration/OrderBookRecoveryTest.java b/src/test/java/com/coinflow/integration/OrderBookRecoveryTest.java index 7ddc280..5ba4bd6 100644 --- a/src/test/java/com/coinflow/integration/OrderBookRecoveryTest.java +++ b/src/test/java/com/coinflow/integration/OrderBookRecoveryTest.java @@ -27,10 +27,12 @@ import org.springframework.http.ResponseEntity; import java.math.BigDecimal; +import java.time.Duration; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SuppressWarnings({"rawtypes", "unchecked"}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -99,10 +101,12 @@ void assertIntegrity() { Long canceledSellOrderId = orderId(canceledSellResponse); cancelOrder(sellerToken, canceledSellOrderId); - assertThat(orderRepository.findById(openBuyOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.OPEN); - assertThat(orderRepository.findById(partialBuyOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.PARTIALLY_FILLED); - assertThat(orderRepository.findById(filledSellOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.FILLED); - assertThat(orderRepository.findById(canceledSellOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.CANCELED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(orderRepository.findById(openBuyOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.OPEN); + assertThat(orderRepository.findById(partialBuyOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.PARTIALLY_FILLED); + assertThat(orderRepository.findById(filledSellOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.FILLED); + assertThat(orderRepository.findById(canceledSellOrderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.CANCELED); + }); matchingEngine.clearAll(); assertThat(matchingEngine.getBuySide("BTC-KRW")).isEmpty(); @@ -134,6 +138,10 @@ void assertIntegrity() { ); Long sellOrderId = orderId(sellResponse); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(sellOrderId).orElseThrow().getStatus()) + .isEqualTo(OrderStatus.OPEN)); + matchingEngine.clearAll(); assertThat(matchingEngine.getSellSide("BTC-KRW")).isEmpty(); @@ -145,10 +153,13 @@ void assertIntegrity() { var buyResponse = createOrder( buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null ); + Long rebuildBuyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); - assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(buyResponse.getBody().get("status")).isEqualTo("FILLED"); - assertThat(tradeRepository.count()).isEqualTo(1); + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(orderRepository.findById(rebuildBuyOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED"); + assertThat(tradeRepository.count()).isEqualTo(1); + }); var sellerOrder = orderRepository.findById(sellOrderId).orElseThrow(); assertThat(sellerOrder.getStatus()).isEqualTo(OrderStatus.PARTIALLY_FILLED); @@ -198,7 +209,7 @@ private void deposit(String email, String asset, BigDecimal amount) { } private Long orderId(ResponseEntity response) { - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); return ((Number) response.getBody().get("orderId")).longValue(); } diff --git a/src/test/java/com/coinflow/integration/WebSocketOrderBookBroadcastIntegrationTest.java b/src/test/java/com/coinflow/integration/WebSocketOrderBookBroadcastIntegrationTest.java index 03215fb..e1ad31b 100644 --- a/src/test/java/com/coinflow/integration/WebSocketOrderBookBroadcastIntegrationTest.java +++ b/src/test/java/com/coinflow/integration/WebSocketOrderBookBroadcastIntegrationTest.java @@ -30,6 +30,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.math.BigDecimal; +import java.time.Duration; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -97,6 +98,8 @@ void assertIntegrity() { depositBtc("orderbook-create-seller@example.com", new BigDecimal("0.001")); createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(matchingEngine.getSellSide("BTC-KRW")).hasSize(1)); outboxPublisher.publishPendingEvents(); OrderBookSnapshotMessage message = awaitOrderBookMessage(); @@ -116,11 +119,15 @@ void assertIntegrity() { depositBtc("orderbook-fill-seller@example.com", new BigDecimal("0.001")); createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(matchingEngine.getSellSide("BTC-KRW")).hasSize(1)); outboxPublisher.publishPendingEvents(); awaitOrderBookMessage(); reset(messagingTemplate); createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(tradeRepository.count()).isEqualTo(1)); outboxPublisher.publishPendingEvents(); OrderBookSnapshotMessage message = awaitOrderBookMessage(); @@ -136,6 +143,8 @@ void assertIntegrity() { Long orderId = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(matchingEngine.getSellSide("BTC-KRW")).hasSize(1)); outboxPublisher.publishPendingEvents(); awaitOrderBookMessage(); reset(messagingTemplate); diff --git a/src/test/java/com/coinflow/integration/WebSocketStompE2eTest.java b/src/test/java/com/coinflow/integration/WebSocketStompE2eTest.java index 6bdfa15..fe0df69 100644 --- a/src/test/java/com/coinflow/integration/WebSocketStompE2eTest.java +++ b/src/test/java/com/coinflow/integration/WebSocketStompE2eTest.java @@ -42,7 +42,10 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SuppressWarnings("rawtypes") @SpringBootTest( @@ -136,6 +139,8 @@ public void handleFrame(StompHeaders headers, Object payload) { createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(tradeRepository.count()).isEqualTo(1)); outboxPublisher.publishPendingEvents(); TradeFeedMessage message = receivedMessages.poll(5, TimeUnit.SECONDS); diff --git a/src/test/java/com/coinflow/integration/WebSocketTradeFeedIntegrationTest.java b/src/test/java/com/coinflow/integration/WebSocketTradeFeedIntegrationTest.java index d0376fa..55c58b3 100644 --- a/src/test/java/com/coinflow/integration/WebSocketTradeFeedIntegrationTest.java +++ b/src/test/java/com/coinflow/integration/WebSocketTradeFeedIntegrationTest.java @@ -26,8 +26,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.math.BigDecimal; +import java.time.Duration; import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; @@ -92,6 +94,8 @@ void assertIntegrity() { createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(tradeRepository.count()).isEqualTo(1)); outboxPublisher.publishPendingEvents(); await().untilAsserted(() -> verify(messagingTemplate) diff --git a/src/test/java/com/coinflow/order/OrderApiTest.java b/src/test/java/com/coinflow/order/OrderApiTest.java index 5c71423..b7077cf 100644 --- a/src/test/java/com/coinflow/order/OrderApiTest.java +++ b/src/test/java/com/coinflow/order/OrderApiTest.java @@ -73,12 +73,17 @@ void assertIntegrity() { depositKrw("order001@example.com", new BigDecimal("10000000")); var response = createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long orderId = ((Number) response.getBody().get("orderId")).longValue(); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.getBody()).containsKeys("orderId", "market", "side", "price", "status"); - assertThat(response.getBody().get("status")).isEqualTo("OPEN"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody()).containsKeys("orderId", "market", "side", "status"); + assertThat(response.getBody().get("status")).isEqualTo("ACCEPTED"); assertThat(response.getBody().get("side")).isEqualTo("BUY"); + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> + assertThat(orderRepository.findById(orderId).orElseThrow().getStatus().name()).isEqualTo("OPEN") + ); + var user = userRepository.findByEmail("order001@example.com").orElseThrow(); var krwWallet = findWallet(user.getId(), "KRW"); assertThat(krwWallet.getLockedBalance()).isEqualByComparingTo("10000"); @@ -91,11 +96,16 @@ void assertIntegrity() { depositBtc("order002@example.com", new BigDecimal("0.001")); var response = createOrder(token, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + Long orderId = ((Number) response.getBody().get("orderId")).longValue(); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.getBody().get("status")).isEqualTo("OPEN"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody().get("status")).isEqualTo("ACCEPTED"); assertThat(response.getBody().get("side")).isEqualTo("SELL"); + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> + assertThat(orderRepository.findById(orderId).orElseThrow().getStatus().name()).isEqualTo("OPEN") + ); + var user = userRepository.findByEmail("order002@example.com").orElseThrow(); var btcWallet = findWallet(user.getId(), "BTC"); assertThat(btcWallet.getLockedBalance()).isEqualByComparingTo("0.0001"); @@ -253,7 +263,11 @@ void assertIntegrity() { depositKrw("order006b@example.com", new BigDecimal("100000000")); var firstResponse = createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", "my-order-2"); - assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> + assertThat(matchingEngine.getBuySide("BTC-KRW")).hasSize(1) + ); long orderCountBefore = orderRepository.count(); long ledgerCountBefore = walletLedgerRepository.count(); @@ -391,20 +405,22 @@ void assertIntegrity() { depositBtc("order014@example.com", new BigDecimal("0.001")); // BUY 주문 먼저 등록 (오더북에 적재) - createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); // SELL 주문 → BUY와 체결 var sellResponse = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + Long sellOrderId = ((Number) sellResponse.getBody().get("orderId")).longValue(); - assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(sellResponse.getBody().get("status")).isEqualTo("FILLED"); + assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(sellResponse.getBody().get("side")).isEqualTo("SELL"); - var trades = (java.util.List) sellResponse.getBody().get("trades"); - assertThat(trades).hasSize(1); - var trade = (Map) trades.get(0); - assertThat(trade.get("price")).isEqualTo("100000000"); - assertThat(trade.get("quantity")).isEqualTo("0.0001"); - assertThat(trade.get("liquidity")).isEqualTo("TAKER"); + // 체결 완료 대기 + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(orderRepository.findById(buyOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED"); + assertThat(orderRepository.findById(sellOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED"); + assertThat(tradeRepository.count()).isEqualTo(1); + }); // buyer: KRW locked 소진, BTC 지급 확인 var buyer = userRepository.findByEmail("order013@example.com").orElseThrow(); @@ -433,9 +449,14 @@ void assertIntegrity() { // SELL 0.0001 BTC → 부분 체결 var sellResponse = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + Long sellOrderId = ((Number) sellResponse.getBody().get("orderId")).longValue(); - assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(sellResponse.getBody().get("status")).isEqualTo("FILLED"); + assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + + // 체결 완료 대기 + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(sellOrderId).orElseThrow().getStatus().name()).isEqualTo("FILLED") + ); // buyer BTC 잔고 확인 var buyer = userRepository.findByEmail("order015@example.com").orElseThrow(); @@ -451,10 +472,13 @@ void assertIntegrity() { createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); var sellResponse = createOrder(token, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + Long sellOrderId = ((Number) sellResponse.getBody().get("orderId")).longValue(); - // self-trade 방지 → taker 전체 거절 - assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(sellResponse.getBody().get("code")).isEqualTo("SELF_TRADE_NOT_ALLOWED"); + // self-trade 방지 → 비동기 워커에서 taker 거절 + assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(sellOrderId).orElseThrow().getStatus().name()).isEqualTo("REJECTED") + ); } @Test @@ -465,21 +489,20 @@ void assertIntegrity() { createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); - long orderCountBefore = orderRepository.count(); - long ledgerCountBefore = walletLedgerRepository.count(); - long eventCountBefore = domainEventRepository.count(); var user = userRepository.findByEmail("order017b@example.com").orElseThrow(); var krwWalletBefore = findWallet(user.getId(), "KRW"); var btcWalletBefore = findWallet(user.getId(), "BTC"); var sellResponse = createOrder(token, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + Long sellOrderId = ((Number) sellResponse.getBody().get("orderId")).longValue(); + + // 비동기 워커에서 자기체결 감지 → taker REJECTED, wallet lock 해제 + assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(orderRepository.findById(sellOrderId).orElseThrow().getStatus().name()).isEqualTo("REJECTED") + ); - assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(sellResponse.getBody().get("code")).isEqualTo("SELF_TRADE_NOT_ALLOWED"); - assertThat(orderRepository.count()).isEqualTo(orderCountBefore); assertThat(tradeRepository.count()).isZero(); - assertThat(walletLedgerRepository.count()).isEqualTo(ledgerCountBefore); - assertThat(domainEventRepository.count()).isEqualTo(eventCountBefore); var krwWalletAfter = findWallet(user.getId(), "KRW"); var btcWalletAfter = findWallet(user.getId(), "BTC"); @@ -539,7 +562,7 @@ private ResponseEntity createOrder(String token, String market, String side private ResponseEntity createAsyncOrder(String token, String market, String side, String type, String timeInForce, String price, String quantity, String clientOrderId) { - return createOrder(token, "/api/v1/orders/async", market, side, type, timeInForce, price, quantity, clientOrderId); + return createOrder(token, "/api/v1/orders", market, side, type, timeInForce, price, quantity, clientOrderId); } private ResponseEntity createOrder(String token, String url, String market, String side, String type, diff --git a/src/test/java/com/coinflow/query/QueryApiTest.java b/src/test/java/com/coinflow/query/QueryApiTest.java index 46eae1a..2b9ef77 100644 --- a/src/test/java/com/coinflow/query/QueryApiTest.java +++ b/src/test/java/com/coinflow/query/QueryApiTest.java @@ -24,7 +24,10 @@ import java.util.List; import java.util.Map; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SuppressWarnings({"rawtypes", "unchecked"}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -107,6 +110,10 @@ void assertIntegrity() { createOrder(buyerToken, "BTC-KRW", "BUY", "100000000", "0.0001"); createOrder(sellerToken, "BTC-KRW", "SELL", "110000000", "0.0001"); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(matchingEngine.getBuySide("BTC-KRW")).hasSize(1); + assertThat(matchingEngine.getSellSide("BTC-KRW")).hasSize(1); + }); var response = restTemplate.getForEntity("/api/v1/markets/BTC-KRW/orderbook", Map.class); @@ -127,6 +134,9 @@ void assertIntegrity() { createOrder(token, "BTC-KRW", "BUY", "90000000", "0.0001"); createOrder(token, "BTC-KRW", "BUY", "100000000", "0.0001"); createOrder(token, "BTC-KRW", "BUY", "80000000", "0.0001"); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(matchingEngine.getBuySide("BTC-KRW")).hasSize(3) + ); var response = restTemplate.getForEntity("/api/v1/markets/BTC-KRW/orderbook", Map.class); List> bids = (List>) response.getBody().get("bids"); @@ -145,6 +155,9 @@ void assertIntegrity() { createOrder(token, "BTC-KRW", "BUY", "100000000", "0.0001"); createOrder(token, "BTC-KRW", "BUY", "100000000", "0.0001"); createOrder(token, "BTC-KRW", "BUY", "90000000", "0.0001"); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(matchingEngine.getBuySide("BTC-KRW")).hasSize(3) + ); var response = restTemplate.getForEntity("/api/v1/markets/BTC-KRW/orderbook?depth=1", Map.class); List> bids = (List>) response.getBody().get("bids"); @@ -165,6 +178,9 @@ void assertIntegrity() { createOrder(buyerToken, "BTC-KRW", "BUY", "100000000", "0.0001"); createOrder(sellerToken, "BTC-KRW", "SELL", "100000000", "0.0001"); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(tradeRepository.count()).isGreaterThan(0) + ); var response = restTemplate.getForEntity("/api/v1/markets/BTC-KRW/trades", List.class); @@ -189,6 +205,9 @@ void assertIntegrity() { createOrder(sellerToken, "BTC-KRW", "SELL", "100000000", "0.0001"); } + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> + assertThat(tradeRepository.count()).isEqualTo(5)); + var response = restTemplate.getForEntity("/api/v1/markets/BTC-KRW/trades?limit=3", List.class); assertThat(((List) response.getBody())).hasSizeLessThanOrEqualTo(3); } @@ -204,6 +223,9 @@ void assertIntegrity() { createOrder(buyerToken, "BTC-KRW", "BUY", "100000000", "0.0001"); createOrder(sellerToken, "BTC-KRW", "SELL", "100000000", "0.0001"); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(tradeRepository.count()).isGreaterThan(0) + ); var buyerFills = getFills(buyerToken, null); assertThat(buyerFills.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -227,6 +249,9 @@ void assertIntegrity() { createOrder(buyerToken, "BTC-KRW", "BUY", "100000000", "0.0001"); createOrder(sellerToken, "BTC-KRW", "SELL", "100000000", "0.0001"); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(tradeRepository.count()).isGreaterThan(0) + ); var response = getFills(buyerToken, "BTC-KRW"); List> fills = (List>) response.getBody(); @@ -248,6 +273,9 @@ void assertIntegrity() { createOrder(buyerToken, "BTC-KRW", "BUY", "100000000", "0.0001"); createOrder(sellerToken, "BTC-KRW", "SELL", "100000000", "0.0001"); + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> + assertThat(tradeRepository.count()).isEqualTo(2)); + var response = getFills(buyerToken, "BTC-KRW", buyOrderId); List> fills = (List>) response.getBody(); diff --git a/src/test/java/com/coinflow/wallet/WalletApiTest.java b/src/test/java/com/coinflow/wallet/WalletApiTest.java index 8f04037..3ed65da 100644 --- a/src/test/java/com/coinflow/wallet/WalletApiTest.java +++ b/src/test/java/com/coinflow/wallet/WalletApiTest.java @@ -26,7 +26,10 @@ import java.util.List; import java.util.Map; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SuppressWarnings({"rawtypes", "unchecked"}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -196,6 +199,9 @@ void assertIntegrity() { createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(tradeRepository.count()).isEqualTo(1)); + var buyer = userRepository.findByEmail("wallet006a@example.com").orElseThrow(); var seller = userRepository.findByEmail("wallet006b@example.com").orElseThrow();