PaymentCancelHandlerTests.cs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  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);
  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);
  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);
  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);
  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 NotPaidOrder_Fails()
  93. {
  94. using var db = TestDb.Create();
  95. var memberID = await TestDb.CreateMemberAsync(db);
  96. var wallet = Wallet.Create(memberID);
  97. await db.Wallet.AddAsync(wallet);
  98. var order = await TestDb.CreateOrderAsync(db, memberID);
  99. var fake = new FakeDanalPayService();
  100. var handler = new CancelHandler(db, fake);
  101. var result = await handler.Handle(new CancelCommand(order.OrderID, memberID), default);
  102. Assert.IsFalse(result.IsSuccess);
  103. Assert.AreEqual(0, fake.CancelCallCount);
  104. }
  105. }