TossWebhookHandlerTests.cs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. using Domain.Entities.Payments.Toss;
  2. using Domain.Entities.Payments.ValueObject;
  3. using Domain.Entities.Wallets;
  4. using Domain.Entities.Wallets.ValueObject;
  5. using Microsoft.EntityFrameworkCore;
  6. using WebhookCommand = Application.Features.Api.Payment.Toss.Webhook.Command;
  7. using WebhookHandler = Application.Features.Api.Payment.Toss.Webhook.Handler;
  8. namespace Application.Tests;
  9. /// <summary>
  10. /// 토스 일반결제 웹훅 — 서명 헤더가 없으므로 본문을 신뢰하지 않고 paymentKey 재조회 결과만 반영하는 계약 검증.
  11. /// </summary>
  12. [TestClass]
  13. public sealed class TossWebhookHandlerTests
  14. {
  15. [TestMethod]
  16. public async Task DepositCallback_Done_CreditsOnce_AndIsIdempotent()
  17. {
  18. // 가상계좌 입금 완료: DEPOSIT_CALLBACK(본문에 paymentKey 없음) → orderId 로 TossConfirm 역참조 →
  19. // 조회 API 재조회 DONE → 멱등 적립. 동일 웹훅 재수신 시 이중적립 금지.
  20. var paymentKey = $"PK-{Guid.NewGuid():N}";
  21. int memberID;
  22. string orderID;
  23. int orderAmount;
  24. using (var setup = TestDb.Create())
  25. {
  26. memberID = await TestDb.CreateMemberAsync(setup);
  27. await setup.Wallet.AddAsync(Wallet.Create(memberID));
  28. var order = await TestDb.CreateOrderAsync(setup, memberID, 10000);
  29. order.MarkWaitingDeposit("9-4444-0000-1234", "20", "개미투자", DateTime.UtcNow.AddDays(7));
  30. await setup.TossConfirm.AddAsync(TossConfirm.CreateRequest(memberID, order.ID, paymentKey, order.OrderID, order.Amount));
  31. await setup.SaveChangesAsync(default);
  32. orderID = order.OrderID;
  33. orderAmount = order.Amount;
  34. }
  35. var fake = new FakeTossPaymentService { GetPaymentResult = FakeTossPaymentService.Done(paymentKey, orderID, orderAmount) };
  36. using (var db = TestDb.Create())
  37. {
  38. var handler = new WebhookHandler(db, fake, FakeAppDbContextFactory.Instance);
  39. var result = await handler.Handle(new WebhookCommand("DEPOSIT_CALLBACK", null, orderID), default);
  40. Assert.IsTrue(result.IsSuccess, "입금 완료 웹훅은 성공 처리되어야 한다");
  41. }
  42. using (var mid = TestDb.Create())
  43. {
  44. var paidOrder = await mid.PaymentOrder.SingleAsync(o => o.OrderID == orderID);
  45. Assert.AreEqual(PaymentStatus.Paid, paidOrder.Status, "재조회 DONE 확인 후 주문이 Paid 로 전이되어야 한다");
  46. var wallet = await mid.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  47. Assert.AreEqual(10000m, wallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "입금 확인 후 캐시가 적립되어야 한다");
  48. }
  49. // 동일 웹훅 재수신 (토스 재시도) — 멱등, 이중적립 금지
  50. using (var db2 = TestDb.Create())
  51. {
  52. var handler2 = new WebhookHandler(db2, fake, FakeAppDbContextFactory.Instance);
  53. var replay = await handler2.Handle(new WebhookCommand("DEPOSIT_CALLBACK", null, orderID), default);
  54. Assert.IsTrue(replay.IsSuccess, "재수신 웹훅도 성공(멱등) 응답이어야 한다");
  55. }
  56. using var verify = TestDb.Create();
  57. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  58. Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "웹훅 재수신 시 이중적립 금지");
  59. Assert.AreEqual(1, savedWallet.Transactions.Count(t => t.TxType == WalletTransactionType.Charge && t.RefID == orderID), "충전 원장은 정확히 1건이어야 한다");
  60. }
  61. [TestMethod]
  62. public async Task UnknownStatus_NoChange_LogsOnly()
  63. {
  64. // 재조회 결과가 판별 불가 상태(IN_PROGRESS 등)면 아무것도 반영하지 않고 로그만 남긴다.
  65. var paymentKey = $"PK-{Guid.NewGuid():N}";
  66. using var db = TestDb.Create();
  67. var memberID = await TestDb.CreateMemberAsync(db);
  68. await db.Wallet.AddAsync(Wallet.Create(memberID));
  69. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  70. var fake = new FakeTossPaymentService
  71. {
  72. GetPaymentResult = FakeTossPaymentService.Done(paymentKey, order.OrderID, order.Amount) with { Status = "IN_PROGRESS" }
  73. };
  74. var handler = new WebhookHandler(db, fake, FakeAppDbContextFactory.Instance);
  75. var result = await handler.Handle(new WebhookCommand("PAYMENT_STATUS_CHANGED", paymentKey, null), default);
  76. Assert.IsTrue(result.IsSuccess, "판별 불가 상태는 정상 응답으로 종결한다 (재시도 무의미)");
  77. using var verify = TestDb.Create();
  78. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  79. Assert.AreEqual(PaymentStatus.Pending, savedOrder.Status, "판별 불가 상태는 주문을 변경하지 않는다");
  80. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  81. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "판별 불가 상태에서는 적립하지 않는다");
  82. var log = await verify.TossLog.Where(c => c.OrderID == order.OrderID && c.Code == "IGNORED").ToListAsync();
  83. Assert.AreEqual(1, log.Count, "IGNORED 웹훅 로그가 남아야 한다");
  84. }
  85. [TestMethod]
  86. public async Task Done_AmountMismatch_DoesNotCredit_WritesReconcileLog()
  87. {
  88. // 재조회 DONE 이라도 금액이 서버 저장 주문 금액과 다르면 적립하지 않고 수동 대사 대상으로 남긴다.
  89. var paymentKey = $"PK-{Guid.NewGuid():N}";
  90. using var db = TestDb.Create();
  91. var memberID = await TestDb.CreateMemberAsync(db);
  92. await db.Wallet.AddAsync(Wallet.Create(memberID));
  93. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  94. var fake = new FakeTossPaymentService { GetPaymentResult = FakeTossPaymentService.Done(paymentKey, order.OrderID, order.Amount + 1000) };
  95. var handler = new WebhookHandler(db, fake, FakeAppDbContextFactory.Instance);
  96. var result = await handler.Handle(new WebhookCommand("PAYMENT_STATUS_CHANGED", paymentKey, null), default);
  97. Assert.IsTrue(result.IsSuccess);
  98. using var verify = TestDb.Create();
  99. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  100. Assert.AreEqual(PaymentStatus.Pending, savedOrder.Status, "금액 불일치는 자동 반영하지 않는다");
  101. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  102. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "금액 불일치 시 적립 금지");
  103. var reconcileLog = await verify.PaymentReconcileLog.Where(c => c.PaymentOrderID == order.ID).ToListAsync();
  104. Assert.AreEqual(1, reconcileLog.Count, "수동 대사용 로그가 남아야 한다");
  105. }
  106. [TestMethod]
  107. public async Task RequeryFails_ReturnsFailure_ForTossRetry()
  108. {
  109. // 재조회 통신 실패는 실패 응답(5xx) — 토스 재시도로 재처리를 유도한다.
  110. var paymentKey = $"PK-{Guid.NewGuid():N}";
  111. using var db = TestDb.Create();
  112. var memberID = await TestDb.CreateMemberAsync(db);
  113. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  114. var fake = new FakeTossPaymentService { GetPaymentException = new HttpRequestException("timeout") };
  115. var handler = new WebhookHandler(db, fake, FakeAppDbContextFactory.Instance);
  116. var result = await handler.Handle(new WebhookCommand("PAYMENT_STATUS_CHANGED", paymentKey, null), default);
  117. Assert.IsFalse(result.IsSuccess, "재조회 실패는 실패 응답으로 토스 재시도를 유도해야 한다");
  118. using var verify = TestDb.Create();
  119. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  120. Assert.AreEqual(PaymentStatus.Pending, savedOrder.Status, "재조회 실패 시 주문은 변경되지 않는다");
  121. }
  122. }