TossReconcileHandlerTests.cs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. using Domain.Entities.Payments.Toss;
  2. using Domain.Entities.Payments.Toss.ValueObject;
  3. using Domain.Entities.Payments.ValueObject;
  4. using Domain.Entities.Wallets;
  5. using Domain.Entities.Wallets.ValueObject;
  6. using Microsoft.EntityFrameworkCore;
  7. using ReconcileCommand = Application.Features.Api.Payment.Toss.Reconcile.Command;
  8. using ReconcileHandler = Application.Features.Api.Payment.Toss.Reconcile.Handler;
  9. namespace Application.Tests;
  10. /// <summary>
  11. /// 자동 대사 배치 핸들러 — NeedsReconciliation 주문을 조회 API 로 재확인해 종결하는 계약 검증.
  12. /// </summary>
  13. [TestClass]
  14. public sealed class TossReconcileHandlerTests
  15. {
  16. private static async Task<(int MemberID, int OrderID, string OrderNo, int Amount, string PaymentKey)> SeedNeedsReconciliationAsync(bool withConfirmRow = true, bool withWallet = true)
  17. {
  18. var paymentKey = $"PK-{Guid.NewGuid():N}";
  19. using var setup = TestDb.Create();
  20. var memberID = await TestDb.CreateMemberAsync(setup);
  21. if (withWallet)
  22. {
  23. await setup.Wallet.AddAsync(Wallet.Create(memberID));
  24. }
  25. var order = await TestDb.CreateOrderAsync(setup, memberID, 10000);
  26. order.MarkNeedsReconciliation("승인 통신 불명(타임아웃)");
  27. if (withConfirmRow)
  28. {
  29. await setup.TossConfirm.AddAsync(TossConfirm.CreateRequest(memberID, order.ID, paymentKey, order.OrderID, order.Amount));
  30. }
  31. await setup.SaveChangesAsync(default);
  32. return (memberID, order.ID, order.OrderID, order.Amount, paymentKey);
  33. }
  34. [TestMethod]
  35. public async Task QueryDone_SettlesPaid_AndIsIdempotent()
  36. {
  37. // 조회 결과 DONE → 멱등 적립 + Paid 종결. 재실행 시(상태 이탈) 아무것도 하지 않는다.
  38. var (memberID, orderPk, orderNo, amount, paymentKey) = await SeedNeedsReconciliationAsync();
  39. var fake = new FakeTossPaymentService { GetPaymentResult = FakeTossPaymentService.Done(paymentKey, orderNo, amount) };
  40. using (var db = TestDb.Create())
  41. {
  42. var handler = new ReconcileHandler(db, fake);
  43. var result = await handler.Handle(new ReconcileCommand(orderPk), default);
  44. Assert.IsTrue(result.IsSuccess, "대사 성공 응답이어야 한다");
  45. }
  46. using (var mid = TestDb.Create())
  47. {
  48. var settled = await mid.PaymentOrder.SingleAsync(o => o.ID == orderPk);
  49. Assert.AreEqual(PaymentStatus.Paid, settled.Status, "DONE 확인 후 Paid 로 종결되어야 한다");
  50. Assert.AreEqual(paymentKey, settled.TransactionID);
  51. var wallet = await mid.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  52. Assert.AreEqual(10000m, wallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "충전 캐시(VAT 제외)가 적립되어야 한다");
  53. var log = await mid.TossLog.Where(c => c.OrderID == orderNo && c.LogType == TossLogType.Reconcile).ToListAsync();
  54. Assert.AreEqual(1, log.Count, "대사 성공 로그가 남아야 한다");
  55. }
  56. // 재실행 — 이미 Paid 라 멱등 skip, 이중적립 금지
  57. using (var db2 = TestDb.Create())
  58. {
  59. var handler2 = new ReconcileHandler(db2, fake);
  60. var again = await handler2.Handle(new ReconcileCommand(orderPk), default);
  61. Assert.IsTrue(again.IsSuccess);
  62. }
  63. using var verify = TestDb.Create();
  64. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  65. Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "대사 재실행 시 이중적립 금지");
  66. Assert.AreEqual(1, savedWallet.Transactions.Count(t => t.TxType == WalletTransactionType.Charge && t.RefID == orderNo));
  67. }
  68. [TestMethod]
  69. public async Task UnknownStatus_NoChange_WritesReconcileLog()
  70. {
  71. // 판별 불가 상태(IN_PROGRESS 등) — 아무것도 반영하지 않고 PaymentReconcileLog 만 남긴다.
  72. var (memberID, orderPk, orderNo, amount, paymentKey) = await SeedNeedsReconciliationAsync();
  73. var fake = new FakeTossPaymentService
  74. {
  75. GetPaymentResult = FakeTossPaymentService.Done(paymentKey, orderNo, amount) with { Status = "IN_PROGRESS" }
  76. };
  77. using var db = TestDb.Create();
  78. var handler = new ReconcileHandler(db, fake);
  79. var result = await handler.Handle(new ReconcileCommand(orderPk), default);
  80. Assert.IsTrue(result.IsSuccess);
  81. using var verify = TestDb.Create();
  82. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.ID == orderPk);
  83. Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "판별 불가면 상태를 유지해 다음 주기에 재시도한다");
  84. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  85. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "판별 불가 상태에서는 적립하지 않는다");
  86. var log = await verify.PaymentReconcileLog.Where(c => c.PaymentOrderID == orderPk).ToListAsync();
  87. Assert.AreEqual(1, log.Count, "판별 불가 기록이 남아야 한다");
  88. Assert.AreEqual(ReconcileKind.AutoReconcile, log[0].Kind);
  89. }
  90. [TestMethod]
  91. public async Task QueryCanceled_MarksFailed_WithoutCredit()
  92. {
  93. // 적립 전 주문의 종결 상태(CANCELED/ABORTED/EXPIRED) — Failed 처리, 지갑 반영 없음.
  94. var (memberID, orderPk, orderNo, amount, paymentKey) = await SeedNeedsReconciliationAsync();
  95. var fake = new FakeTossPaymentService
  96. {
  97. GetPaymentResult = FakeTossPaymentService.Done(paymentKey, orderNo, amount) with { Status = "ABORTED" }
  98. };
  99. using var db = TestDb.Create();
  100. var handler = new ReconcileHandler(db, fake);
  101. var result = await handler.Handle(new ReconcileCommand(orderPk), default);
  102. Assert.IsTrue(result.IsSuccess);
  103. using var verify = TestDb.Create();
  104. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.ID == orderPk);
  105. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status, "종결 상태는 Failed 로 마감한다");
  106. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  107. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
  108. }
  109. [TestMethod]
  110. public async Task NonTossOrder_IsSkipped_WithoutPgCall()
  111. {
  112. // TossConfirm 행이 없는 주문(=타 PG) — 오처리 방지를 위해 skip.
  113. var (_, orderPk, _, _, _) = await SeedNeedsReconciliationAsync(withConfirmRow: false);
  114. var fake = new FakeTossPaymentService();
  115. using var db = TestDb.Create();
  116. var handler = new ReconcileHandler(db, fake);
  117. var result = await handler.Handle(new ReconcileCommand(orderPk), default);
  118. Assert.IsTrue(result.IsSuccess);
  119. Assert.AreEqual(0, fake.GetPaymentCallCount, "토스 주문이 아니면 조회 API 를 호출하지 않는다");
  120. using var verify = TestDb.Create();
  121. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.ID == orderPk);
  122. Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "타 PG 주문 상태는 건드리지 않는다");
  123. }
  124. [TestMethod]
  125. public async Task QueryCommException_LeavesState_ForNextCycle()
  126. {
  127. // 조회 통신 실패 — 아무것도 반영하지 않고 로그만, 다음 주기에 재시도.
  128. var (_, orderPk, _, _, _) = await SeedNeedsReconciliationAsync();
  129. var fake = new FakeTossPaymentService { GetPaymentException = new HttpRequestException("timeout") };
  130. using var db = TestDb.Create();
  131. var handler = new ReconcileHandler(db, fake);
  132. var result = await handler.Handle(new ReconcileCommand(orderPk), default);
  133. Assert.IsTrue(result.IsSuccess, "조회 실패는 배치를 중단시키지 않는다");
  134. using var verify = TestDb.Create();
  135. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.ID == orderPk);
  136. Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "통신 실패 시 상태를 유지한다");
  137. var log = await verify.PaymentReconcileLog.Where(c => c.PaymentOrderID == orderPk).ToListAsync();
  138. Assert.AreEqual(1, log.Count, "조회 실패 기록이 남아야 한다");
  139. }
  140. }