PaymentConfirmHandlerTests.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  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 ConfirmCommand = Application.Features.Api.Payment.Confirm.Command;
  7. using ConfirmHandler = Application.Features.Api.Payment.Confirm.Handler;
  8. namespace Application.Tests;
  9. [TestClass]
  10. public sealed class PaymentConfirmHandlerTests
  11. {
  12. [TestMethod]
  13. public async Task Success_CreditsPgCharged_And_MarksPaid()
  14. {
  15. using var db = TestDb.Create();
  16. var memberID = await TestDb.CreateMemberAsync(db);
  17. var wallet = Wallet.Create(memberID);
  18. await db.Wallet.AddAsync(wallet);
  19. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  20. var fake = new FakeDanalPayService();
  21. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  22. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-1", "CARD", memberID), default);
  23. Assert.IsTrue(resp.Success);
  24. Assert.AreEqual(10000, resp.PointAmount);
  25. using var verify = TestDb.Create();
  26. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  27. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status);
  28. Assert.AreEqual("TX-1", savedOrder.TransactionID);
  29. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  30. Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
  31. Assert.IsTrue(savedWallet.Transactions.Any(t => t.TxType == WalletTransactionType.Charge && t.Amount.Value == 10000m && t.RefID == order.OrderID));
  32. }
  33. [TestMethod]
  34. public async Task WalletMissing_CreatesWallet_And_PersistsPaid()
  35. {
  36. // P0 #1 재현: PG 승인 성공 후 지갑이 없으면 MarkPaid 가 저장되지 않고 유실됐다.
  37. // 기대 행위: 지갑을 생성해 충전을 반영하고 주문을 Paid 로 저장한다.
  38. using var db = TestDb.Create();
  39. var memberID = await TestDb.CreateMemberAsync(db);
  40. var order = await TestDb.CreateOrderAsync(db, memberID, 5000);
  41. var fake = new FakeDanalPayService();
  42. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  43. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-2", "CARD", memberID), default);
  44. Assert.IsTrue(resp.Success, $"승인 성공이 반영되어야 한다: {resp.Message}");
  45. using var verify = TestDb.Create();
  46. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  47. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "PG 승인 성공이 DB에 기록되어야 한다");
  48. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleOrDefaultAsync(w => w.MemberID == memberID);
  49. Assert.IsNotNull(savedWallet, "지갑이 자동 생성되어야 한다");
  50. Assert.AreEqual(5000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
  51. }
  52. [TestMethod]
  53. public async Task PgCommException_MarksNeedsReconciliation()
  54. {
  55. // P0 #2 + Phase 1.5 계약 변경: PG 통신 불명 예외(HttpRequestException/TaskCanceledException)는
  56. // 다날이 실제 승인했을 수 있어 Failed 로 단정하지 않고 NeedsReconciliation 으로 표시한다.
  57. // (기존 "통신예외 → Failed" 테스트를 갱신 — Pending 방치가 없어야 한다는 원 취지는 유지)
  58. using var db = TestDb.Create();
  59. var memberID = await TestDb.CreateMemberAsync(db);
  60. var wallet = Wallet.Create(memberID);
  61. await db.Wallet.AddAsync(wallet);
  62. var order = await TestDb.CreateOrderAsync(db, memberID);
  63. var fake = new FakeDanalPayService { ConfirmException = new HttpRequestException("network error") };
  64. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  65. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-3", "CARD", memberID), default);
  66. Assert.IsFalse(resp.Success);
  67. using var verify = TestDb.Create();
  68. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  69. Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "PG 통신 불명은 대사 대상(NeedsReconciliation)으로 기록되어야 한다");
  70. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  71. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "승인 확정 전에는 충전되지 않는다");
  72. }
  73. [TestMethod]
  74. public async Task PgNonCommException_MarksOrderFailed()
  75. {
  76. // 통신 불명이 아닌 예외(설정 오류 등)는 기존대로 Failed — Pending 방치를 막는다.
  77. using var db = TestDb.Create();
  78. var memberID = await TestDb.CreateMemberAsync(db);
  79. var wallet = Wallet.Create(memberID);
  80. await db.Wallet.AddAsync(wallet);
  81. var order = await TestDb.CreateOrderAsync(db, memberID);
  82. var fake = new FakeDanalPayService { ConfirmException = new InvalidOperationException("danal config error") };
  83. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  84. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-3N", "CARD", memberID), default);
  85. Assert.IsFalse(resp.Success);
  86. using var verify = TestDb.Create();
  87. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  88. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status, "통신 불명 이외의 예외는 주문 Failed 로 기록되어야 한다");
  89. }
  90. [TestMethod]
  91. public async Task NeedsReconciliation_ReConfirm_Succeeds_ReusingConfirmRow()
  92. {
  93. // Phase 1.5: 통신 불명(NeedsReconciliation) 주문은 재승인이 허용되어야 하고,
  94. // DanalConfirm 은 TransactionID/OrderID unique 라 기존 요청 행을 재사용해야 한다.
  95. using var db = TestDb.Create();
  96. var memberID = await TestDb.CreateMemberAsync(db);
  97. var wallet = Wallet.Create(memberID);
  98. await db.Wallet.AddAsync(wallet);
  99. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  100. var failFake = new FakeDanalPayService { ConfirmException = new TaskCanceledException("timeout") };
  101. var handler = new ConfirmHandler(db, failFake, FakeAppDbContextFactory.Instance);
  102. var first = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-RECON-A", "CARD", memberID), default);
  103. Assert.IsFalse(first.Success);
  104. using (var mid = TestDb.Create())
  105. {
  106. var midOrder = await mid.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  107. Assert.AreEqual(PaymentStatus.NeedsReconciliation, midOrder.Status);
  108. }
  109. using var db2 = TestDb.Create();
  110. var handler2 = new ConfirmHandler(db2, new FakeDanalPayService(), FakeAppDbContextFactory.Instance);
  111. var second = await handler2.Handle(new ConfirmCommand(order.OrderID, "TX-RECON-B", "CARD", memberID), default);
  112. Assert.IsTrue(second.Success, $"NeedsReconciliation 재승인은 성공해야 한다: {second.Message}");
  113. using var verify = TestDb.Create();
  114. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  115. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status);
  116. Assert.AreEqual("TX-RECON-B", savedOrder.TransactionID);
  117. var confirms = await verify.DanalConfirm.Where(c => c.OrderID == order.OrderID).ToListAsync();
  118. Assert.AreEqual(1, confirms.Count, "재승인은 기존 DanalConfirm 행을 재사용해야 한다 (unique 인덱스 충돌 금지)");
  119. Assert.AreEqual("TX-RECON-B", confirms[0].TransactionID);
  120. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  121. Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
  122. Assert.AreEqual(1, savedWallet.Transactions.Count(t => t.TxType == WalletTransactionType.Charge && t.RefID == order.OrderID), "충전 원장은 정확히 1건이어야 한다");
  123. }
  124. [TestMethod]
  125. public async Task ConcurrencyConflict_RetriesOnce_WithoutDoubleCredit()
  126. {
  127. // Phase 1.5: PG 승인 성공 후 WalletBalance rowversion 충돌 시 새 컨텍스트로 1회 재적용 —
  128. // 승인금 유실 없이 성공하고, 원장 이중적립이 없어야 한다.
  129. int memberID;
  130. string orderID;
  131. using (var setup = TestDb.Create())
  132. {
  133. memberID = await TestDb.CreateMemberAsync(setup);
  134. await setup.Wallet.AddAsync(Wallet.Create(memberID));
  135. var order = await TestDb.CreateOrderAsync(setup, memberID, 10000);
  136. orderID = order.OrderID;
  137. }
  138. using var db = TestDb.Create();
  139. // 핸들러 컨텍스트에 지갑/잔액을 미리 추적시켜 stale rowversion 상태를 만든다
  140. _ = await db.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  141. // 다른 컨텍스트가 동시 충전 → DB rowversion 전진 (핸들러의 최종 저장이 충돌하게 된다)
  142. using (var other = TestDb.Create())
  143. {
  144. var concurrent = await other.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  145. concurrent.CreditPgCharge(Money.KRW(3000), "동시 충전", "CONCURRENT");
  146. await other.SaveChangesAsync(default);
  147. }
  148. var fake = new FakeDanalPayService();
  149. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  150. var resp = await handler.Handle(new ConfirmCommand(orderID, "TX-CONC", "CARD", memberID), default);
  151. Assert.IsTrue(resp.Success, $"동시성 충돌은 1회 재시도로 복구되어야 한다: {resp.Message}");
  152. Assert.AreEqual(1, fake.ConfirmCallCount, "재시도는 저장만 다시 한다 — PG 재호출 금지");
  153. using var verify = TestDb.Create();
  154. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == orderID);
  155. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status);
  156. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  157. Assert.AreEqual(13000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "동시 충전(3000)과 승인 충전(10000)이 모두 반영되어야 한다");
  158. Assert.AreEqual(1, savedWallet.Transactions.Count(t => t.TxType == WalletTransactionType.Charge && t.RefID == orderID), "원장 이중적립 금지");
  159. }
  160. [TestMethod]
  161. public async Task AlreadyPaid_IsIdempotent_WithoutPgCall()
  162. {
  163. using var db = TestDb.Create();
  164. var memberID = await TestDb.CreateMemberAsync(db);
  165. var order = await TestDb.CreateOrderAsync(db, memberID, 7000, paid: true);
  166. var fake = new FakeDanalPayService();
  167. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  168. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-RETRY", "CARD", memberID), default);
  169. Assert.IsTrue(resp.Success);
  170. Assert.AreEqual(7000, resp.PointAmount);
  171. Assert.AreEqual(0, fake.ConfirmCallCount, "이미 완료된 주문은 PG 를 재호출하지 않는다");
  172. }
  173. [TestMethod]
  174. public async Task FailedOrder_ReConfirm_IsRejected_WithoutPgCall_And_NoCrash()
  175. {
  176. // 리뷰 #1/#9 재현: Failed 주문이 재진입하면 PG 재호출 후 MarkPaid 가 예외를 던져 승인금이 유실됐다.
  177. // 기대 행위: 진입 가드가 PG 호출 전에 거절하고, 예외 없이 실패 응답.
  178. using var db = TestDb.Create();
  179. var memberID = await TestDb.CreateMemberAsync(db);
  180. var wallet = Wallet.Create(memberID);
  181. await db.Wallet.AddAsync(wallet);
  182. var order = await TestDb.CreateOrderAsync(db, memberID);
  183. order.MarkFailed("이전 승인 실패");
  184. await db.SaveChangesAsync(default);
  185. var fake = new FakeDanalPayService();
  186. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  187. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-RE", "CARD", memberID), default);
  188. Assert.IsFalse(resp.Success);
  189. Assert.AreEqual(0, fake.ConfirmCallCount, "종료된 주문은 PG 를 재호출하지 않는다");
  190. using var verify = TestDb.Create();
  191. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  192. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status);
  193. }
  194. [TestMethod]
  195. public async Task MemberMismatch_DoesNotPoisonVictimOrder()
  196. {
  197. // 리뷰 #1 공격벡터 재현: 회원 불일치 분기가 피해자의 Pending 주문을 Failed 로 저장해
  198. // 이후 정상 승인을 MarkPaid 크래시로 유도할 수 있었다.
  199. // 기대 행위: 불일치는 주문 상태를 건드리지 않고, 피해자의 정상 승인이 뒤이어 성공한다.
  200. using var db = TestDb.Create();
  201. var victimID = await TestDb.CreateMemberAsync(db);
  202. var attackerID = await TestDb.CreateMemberAsync(db);
  203. var wallet = Wallet.Create(victimID);
  204. await db.Wallet.AddAsync(wallet);
  205. var order = await TestDb.CreateOrderAsync(db, victimID, 9000);
  206. var fake = new FakeDanalPayService();
  207. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  208. var attack = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-ATK", "CARD", attackerID), default);
  209. Assert.IsFalse(attack.Success, "타인의 주문 승인은 거절되어야 한다");
  210. using (var mid = TestDb.Create())
  211. {
  212. var afterAttack = await mid.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  213. Assert.AreEqual(PaymentStatus.Pending, afterAttack.Status, "불일치는 피해자 주문을 오염시키지 않아야 한다");
  214. }
  215. using var db2 = TestDb.Create();
  216. var handler2 = new ConfirmHandler(db2, new FakeDanalPayService(), FakeAppDbContextFactory.Instance);
  217. var legit = await handler2.Handle(new ConfirmCommand(order.OrderID, "TX-OK", "CARD", victimID), default);
  218. Assert.IsTrue(legit.Success, "피해자의 정상 승인은 크래시 없이 성공해야 한다");
  219. }
  220. [TestMethod]
  221. public async Task PgDeclines_MarksOrderFailed()
  222. {
  223. using var db = TestDb.Create();
  224. var memberID = await TestDb.CreateMemberAsync(db);
  225. var wallet = Wallet.Create(memberID);
  226. await db.Wallet.AddAsync(wallet);
  227. var order = await TestDb.CreateOrderAsync(db, memberID);
  228. var fake = new FakeDanalPayService { ConfirmResult = FakeDanalPayService.FailConfirm() };
  229. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  230. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-4", "CARD", memberID), default);
  231. Assert.IsFalse(resp.Success);
  232. using var verify = TestDb.Create();
  233. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  234. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status);
  235. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  236. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "승인 거절 시 충전되지 않는다");
  237. }
  238. }