PaymentConfirmHandlerTests.cs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  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);
  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);
  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 PgThrows_MarksOrderFailed()
  54. {
  55. // P0 #2 재현: PG 호출 예외 시 주문이 Pending 으로 방치됐다.
  56. // 기대 행위: 주문을 Failed 로 기록한다.
  57. using var db = TestDb.Create();
  58. var memberID = await TestDb.CreateMemberAsync(db);
  59. var wallet = Wallet.Create(memberID);
  60. await db.Wallet.AddAsync(wallet);
  61. var order = await TestDb.CreateOrderAsync(db, memberID);
  62. var fake = new FakeDanalPayService { ConfirmException = new HttpRequestException("network error") };
  63. var handler = new ConfirmHandler(db, fake);
  64. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-3", "CARD", memberID), default);
  65. Assert.IsFalse(resp.Success);
  66. using var verify = TestDb.Create();
  67. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  68. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status, "PG 통신 오류는 주문 Failed 로 기록되어야 한다");
  69. }
  70. [TestMethod]
  71. public async Task AlreadyPaid_IsIdempotent_WithoutPgCall()
  72. {
  73. using var db = TestDb.Create();
  74. var memberID = await TestDb.CreateMemberAsync(db);
  75. var order = await TestDb.CreateOrderAsync(db, memberID, 7000, paid: true);
  76. var fake = new FakeDanalPayService();
  77. var handler = new ConfirmHandler(db, fake);
  78. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-RETRY", "CARD", memberID), default);
  79. Assert.IsTrue(resp.Success);
  80. Assert.AreEqual(7000, resp.PointAmount);
  81. Assert.AreEqual(0, fake.ConfirmCallCount, "이미 완료된 주문은 PG 를 재호출하지 않는다");
  82. }
  83. [TestMethod]
  84. public async Task FailedOrder_ReConfirm_IsRejected_WithoutPgCall_And_NoCrash()
  85. {
  86. // 리뷰 #1/#9 재현: Failed 주문이 재진입하면 PG 재호출 후 MarkPaid 가 예외를 던져 승인금이 유실됐다.
  87. // 기대 행위: 진입 가드가 PG 호출 전에 거절하고, 예외 없이 실패 응답.
  88. using var db = TestDb.Create();
  89. var memberID = await TestDb.CreateMemberAsync(db);
  90. var wallet = Wallet.Create(memberID);
  91. await db.Wallet.AddAsync(wallet);
  92. var order = await TestDb.CreateOrderAsync(db, memberID);
  93. order.MarkFailed("이전 승인 실패");
  94. await db.SaveChangesAsync(default);
  95. var fake = new FakeDanalPayService();
  96. var handler = new ConfirmHandler(db, fake);
  97. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-RE", "CARD", memberID), default);
  98. Assert.IsFalse(resp.Success);
  99. Assert.AreEqual(0, fake.ConfirmCallCount, "종료된 주문은 PG 를 재호출하지 않는다");
  100. using var verify = TestDb.Create();
  101. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  102. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status);
  103. }
  104. [TestMethod]
  105. public async Task MemberMismatch_DoesNotPoisonVictimOrder()
  106. {
  107. // 리뷰 #1 공격벡터 재현: 회원 불일치 분기가 피해자의 Pending 주문을 Failed 로 저장해
  108. // 이후 정상 승인을 MarkPaid 크래시로 유도할 수 있었다.
  109. // 기대 행위: 불일치는 주문 상태를 건드리지 않고, 피해자의 정상 승인이 뒤이어 성공한다.
  110. using var db = TestDb.Create();
  111. var victimID = await TestDb.CreateMemberAsync(db);
  112. var attackerID = await TestDb.CreateMemberAsync(db);
  113. var wallet = Wallet.Create(victimID);
  114. await db.Wallet.AddAsync(wallet);
  115. var order = await TestDb.CreateOrderAsync(db, victimID, 9000);
  116. var fake = new FakeDanalPayService();
  117. var handler = new ConfirmHandler(db, fake);
  118. var attack = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-ATK", "CARD", attackerID), default);
  119. Assert.IsFalse(attack.Success, "타인의 주문 승인은 거절되어야 한다");
  120. using (var mid = TestDb.Create())
  121. {
  122. var afterAttack = await mid.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  123. Assert.AreEqual(PaymentStatus.Pending, afterAttack.Status, "불일치는 피해자 주문을 오염시키지 않아야 한다");
  124. }
  125. using var db2 = TestDb.Create();
  126. var handler2 = new ConfirmHandler(db2, new FakeDanalPayService());
  127. var legit = await handler2.Handle(new ConfirmCommand(order.OrderID, "TX-OK", "CARD", victimID), default);
  128. Assert.IsTrue(legit.Success, "피해자의 정상 승인은 크래시 없이 성공해야 한다");
  129. }
  130. [TestMethod]
  131. public async Task PgDeclines_MarksOrderFailed()
  132. {
  133. using var db = TestDb.Create();
  134. var memberID = await TestDb.CreateMemberAsync(db);
  135. var wallet = Wallet.Create(memberID);
  136. await db.Wallet.AddAsync(wallet);
  137. var order = await TestDb.CreateOrderAsync(db, memberID);
  138. var fake = new FakeDanalPayService { ConfirmResult = FakeDanalPayService.FailConfirm() };
  139. var handler = new ConfirmHandler(db, fake);
  140. var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-4", "CARD", memberID), default);
  141. Assert.IsFalse(resp.Success);
  142. using var verify = TestDb.Create();
  143. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  144. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status);
  145. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  146. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "승인 거절 시 충전되지 않는다");
  147. }
  148. }