PaymentCancelHandlerTests.cs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. using Domain.Entities.Common.ValueObject;
  2. using Domain.Entities.Payments.ValueObject;
  3. using Domain.Entities.Wallets;
  4. using Domain.Entities.Wallets.ValueObject;
  5. using Microsoft.EntityFrameworkCore;
  6. using CancelCommand = Application.Features.Api.Payment.Cancel.Command;
  7. using CancelHandler = Application.Features.Api.Payment.Cancel.Handler;
  8. namespace Application.Tests;
  9. [TestClass]
  10. public sealed class PaymentCancelHandlerTests
  11. {
  12. [TestMethod]
  13. public async Task Cancel_RefundsPgCharged_WithChargeCancelledTx()
  14. {
  15. // P0 #3 재현: 취소 환불이 Balances 미로딩 + Adjusted 버킷 차감으로
  16. // Danal 취소 성공 후 예외가 발생했다 (PG-지갑 desync).
  17. // 기대 행위: PgCharged 에서 회수하고 ChargeCancelled 트랜잭션을 기록한다.
  18. using var db = TestDb.Create();
  19. var memberID = await TestDb.CreateMemberAsync(db);
  20. var wallet = Wallet.Create(memberID);
  21. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  22. await db.Wallet.AddAsync(wallet);
  23. var order = await TestDb.CreateOrderAsync(db, memberID, 10000, paid: true);
  24. var fake = new FakeDanalPayService();
  25. var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance);
  26. var result = await handler.Handle(new CancelCommand(order.OrderID, memberID), default);
  27. Assert.IsTrue(result.IsSuccess, $"취소가 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Description)}");
  28. using var verify = TestDb.Create();
  29. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  30. Assert.AreEqual(PaymentStatus.Cancelled, savedOrder.Status);
  31. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  32. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "환불은 PgCharged 에서 회수한다");
  33. Assert.IsTrue(savedWallet.Transactions.Any(t => t.TxType == WalletTransactionType.ChargeCancelled && t.Amount.Value == 10000m && t.RefID == order.OrderID), "ChargeCancelled 원장 기록이 있어야 한다");
  34. }
  35. [TestMethod]
  36. public async Task PointsAlreadySpent_Fails_BeforePgCall()
  37. {
  38. // 기대 행위: 충전 포인트를 이미 사용했다면 PG 호출 전에 거절한다.
  39. using var db = TestDb.Create();
  40. var memberID = await TestDb.CreateMemberAsync(db);
  41. var wallet = Wallet.Create(memberID);
  42. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  43. wallet.Spend(Money.KRW(8000), "사용");
  44. await db.Wallet.AddAsync(wallet);
  45. var order = await TestDb.CreateOrderAsync(db, memberID, 10000, paid: true);
  46. var fake = new FakeDanalPayService();
  47. var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance);
  48. var result = await handler.Handle(new CancelCommand(order.OrderID, memberID), default);
  49. Assert.IsFalse(result.IsSuccess, "사용분이 있으면 취소를 거절해야 한다");
  50. Assert.AreEqual(0, fake.CancelCallCount, "거절은 PG 호출 전에 이뤄져야 한다");
  51. using var verify = TestDb.Create();
  52. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  53. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "주문 상태는 그대로 Paid 여야 한다");
  54. }
  55. [TestMethod]
  56. public async Task WalletMissing_Fails_BeforePgCall()
  57. {
  58. // P0 #3 파생 재현: 기존 코드는 지갑이 없으면 PG 취소 후 포인트 회수를 조용히 건너뛰었다
  59. // (사용자가 돈을 돌려받고 포인트도 유지). 기대 행위: PG 호출 전에 거절한다.
  60. using var db = TestDb.Create();
  61. var memberID = await TestDb.CreateMemberAsync(db);
  62. var order = await TestDb.CreateOrderAsync(db, memberID, 10000, paid: true);
  63. var fake = new FakeDanalPayService();
  64. var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance);
  65. var result = await handler.Handle(new CancelCommand(order.OrderID, memberID), default);
  66. Assert.IsFalse(result.IsSuccess, "지갑이 없으면 취소를 거절해야 한다");
  67. Assert.AreEqual(0, fake.CancelCallCount, "거절은 PG 호출 전에 이뤄져야 한다");
  68. }
  69. [TestMethod]
  70. public async Task PgCommException_LeavesOrderPaid_And_WalletUntouched()
  71. {
  72. // 리뷰 #8 재현: PG 취소 통신 예외 경로가 무테스트라, DebitChargeCancel 을 PG 앞으로 옮기는
  73. // 회귀(선차감 후 PG 실패 → 실제 미취소인데 포인트 차감)를 잡지 못했다.
  74. // 기대 행위: 예외 시 지갑 무변경 + 주문 Paid 유지.
  75. using var db = TestDb.Create();
  76. var memberID = await TestDb.CreateMemberAsync(db);
  77. var wallet = Wallet.Create(memberID);
  78. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  79. await db.Wallet.AddAsync(wallet);
  80. var order = await TestDb.CreateOrderAsync(db, memberID, 10000, paid: true);
  81. var fake = new FakeDanalPayService { CancelException = new HttpRequestException("timeout") };
  82. var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance);
  83. var result = await handler.Handle(new CancelCommand(order.OrderID, memberID), default);
  84. Assert.IsFalse(result.IsSuccess);
  85. using var verify = TestDb.Create();
  86. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  87. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "PG 취소 실패 시 주문은 Paid 로 유지");
  88. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  89. Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "PG 미취소 시 포인트는 차감되지 않아야 한다");
  90. }
  91. [TestMethod]
  92. public async Task ConflictThenPointsSpent_WritesReconcileLog_AndThrows()
  93. {
  94. // Phase 1.5: PG 취소 성공 후 저장이 동시성 충돌로 실패했고, 그 사이 포인트가 사용돼
  95. // 재시도(리로드 재적용)도 불가능한 경우 — PG 는 이미 환불됐으므로 자동 복구 불가.
  96. // 기대 행위: PaymentReconcileLog 를 독립 커밋으로 남기고 예외를 전파한다(기존 실패 흐름 유지).
  97. int memberID;
  98. string orderID;
  99. int orderPk;
  100. using (var setup = TestDb.Create())
  101. {
  102. memberID = await TestDb.CreateMemberAsync(setup);
  103. var wallet = Wallet.Create(memberID);
  104. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  105. await setup.Wallet.AddAsync(wallet);
  106. var order = await TestDb.CreateOrderAsync(setup, memberID, 10000, paid: true);
  107. orderID = order.OrderID;
  108. orderPk = order.ID;
  109. }
  110. using var db = TestDb.Create();
  111. // 핸들러 컨텍스트에 지갑/잔액을 미리 추적시켜 stale rowversion 상태를 만든다
  112. // (선검증은 stale 잔액 10000 을 보고 통과 → PG 취소 성공 → 저장에서 충돌)
  113. _ = await db.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  114. using (var other = TestDb.Create())
  115. {
  116. var concurrent = await other.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  117. concurrent.Spend(Money.KRW(8000), "동시 사용");
  118. await other.SaveChangesAsync(default);
  119. }
  120. var fake = new FakeDanalPayService();
  121. var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance);
  122. await Assert.ThrowsExceptionAsync<InvalidOperationException>(() => handler.Handle(new CancelCommand(orderID, memberID), default));
  123. Assert.AreEqual(1, fake.CancelCallCount, "PG 취소는 1회만 호출되어야 한다");
  124. using var verify = TestDb.Create();
  125. var log = await verify.PaymentReconcileLog.SingleOrDefaultAsync(l => l.PaymentOrderID == orderPk);
  126. Assert.IsNotNull(log, "대사 로그가 독립 커밋으로 남아야 한다");
  127. Assert.AreEqual(ReconcileKind.Cancel, log.Kind);
  128. Assert.AreEqual(10000, log.Amount);
  129. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == orderID);
  130. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "재시도 실패 시 로컬 상태는 미변경(수동 대사 대상)이어야 한다");
  131. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  132. Assert.AreEqual(2000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "동시 사용 반영 외 지갑 변경이 없어야 한다");
  133. }
  134. [TestMethod]
  135. public async Task NotPaidOrder_Fails()
  136. {
  137. using var db = TestDb.Create();
  138. var memberID = await TestDb.CreateMemberAsync(db);
  139. var wallet = Wallet.Create(memberID);
  140. await db.Wallet.AddAsync(wallet);
  141. var order = await TestDb.CreateOrderAsync(db, memberID);
  142. var fake = new FakeDanalPayService();
  143. var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance);
  144. var result = await handler.Handle(new CancelCommand(order.OrderID, memberID), default);
  145. Assert.IsFalse(result.IsSuccess);
  146. Assert.AreEqual(0, fake.CancelCallCount);
  147. }
  148. }