TossConfirmHandlerTests.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. using Domain.Entities.Common.ValueObject;
  2. using Domain.Entities.Payments.Toss.ValueObject;
  3. using Domain.Entities.Payments.ValueObject;
  4. using Domain.Entities.Wallets;
  5. using Domain.Entities.Wallets.ValueObject;
  6. using Microsoft.EntityFrameworkCore;
  7. using ConfirmCommand = Application.Features.Api.Payment.Toss.Confirm.Command;
  8. using ConfirmHandler = Application.Features.Api.Payment.Toss.Confirm.Handler;
  9. namespace Application.Tests;
  10. [TestClass]
  11. public sealed class TossConfirmHandlerTests
  12. {
  13. [TestMethod]
  14. public async Task Success_CreditsPgCharged_And_MarksPaid()
  15. {
  16. using var db = TestDb.Create();
  17. var memberID = await TestDb.CreateMemberAsync(db);
  18. var wallet = Wallet.Create(memberID);
  19. await db.Wallet.AddAsync(wallet);
  20. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  21. var fake = new FakeTossPaymentService();
  22. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  23. var resp = await handler.Handle(new ConfirmCommand("PK-1", order.OrderID, order.Amount, memberID), default);
  24. Assert.IsTrue(resp.Success, $"승인이 성공해야 한다: {resp.Message}");
  25. Assert.AreEqual(10000, resp.PointAmount);
  26. using var verify = TestDb.Create();
  27. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  28. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status);
  29. Assert.AreEqual("PK-1", savedOrder.TransactionID, "MarkPaid 시 paymentKey 가 TransactionID 에 저장되어야 한다");
  30. var savedConfirm = await verify.TossConfirm.SingleAsync(c => c.OrderID == order.OrderID);
  31. Assert.AreEqual("DONE", savedConfirm.Status);
  32. Assert.IsTrue(savedConfirm.IsSuccess);
  33. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  34. Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
  35. Assert.IsTrue(savedWallet.Transactions.Any(t => t.TxType == WalletTransactionType.Charge && t.Amount.Value == 10000m && t.RefID == order.OrderID));
  36. }
  37. [TestMethod]
  38. public async Task AmountMismatch_Fails_BeforePgCall()
  39. {
  40. // Toss 계약: 클라이언트 전달 amount ≠ 서버 저장 주문 금액이면 PG 호출 전에 즉시 실패 (변조 차단)
  41. using var db = TestDb.Create();
  42. var memberID = await TestDb.CreateMemberAsync(db);
  43. var wallet = Wallet.Create(memberID);
  44. await db.Wallet.AddAsync(wallet);
  45. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  46. var fake = new FakeTossPaymentService();
  47. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  48. var resp = await handler.Handle(new ConfirmCommand("PK-TAMPER", order.OrderID, order.Amount - 1000, memberID), default);
  49. Assert.IsFalse(resp.Success);
  50. Assert.AreEqual(0, fake.ConfirmCallCount, "금액 불일치는 PG 호출 전에 거절되어야 한다");
  51. using var verify = TestDb.Create();
  52. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  53. Assert.AreEqual(PaymentStatus.Pending, savedOrder.Status, "금액 불일치는 주문 상태를 종결하지 않는다 (정상 재시도 허용)");
  54. var log = await verify.TossLog.Where(c => c.OrderID == order.OrderID && c.Code == "AMOUNT_MISMATCH").ToListAsync();
  55. Assert.AreEqual(1, log.Count, "AMOUNT_MISMATCH 로그가 남아야 한다");
  56. }
  57. [TestMethod]
  58. public async Task WalletMissing_CreatesWallet_And_PersistsPaid()
  59. {
  60. // PG 승인 성공 후 지갑이 없어도 MarkPaid 가 유실되지 않아야 한다 — 지갑을 생성해 반영.
  61. using var db = TestDb.Create();
  62. var memberID = await TestDb.CreateMemberAsync(db);
  63. var order = await TestDb.CreateOrderAsync(db, memberID, 5000);
  64. var fake = new FakeTossPaymentService();
  65. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  66. var resp = await handler.Handle(new ConfirmCommand("PK-2", order.OrderID, order.Amount, memberID), default);
  67. Assert.IsTrue(resp.Success, $"승인 성공이 반영되어야 한다: {resp.Message}");
  68. using var verify = TestDb.Create();
  69. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  70. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "PG 승인 성공이 DB에 기록되어야 한다");
  71. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleOrDefaultAsync(w => w.MemberID == memberID);
  72. Assert.IsNotNull(savedWallet, "지갑이 자동 생성되어야 한다");
  73. Assert.AreEqual(5000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
  74. }
  75. [TestMethod]
  76. public async Task PgCommException_MarksNeedsReconciliation()
  77. {
  78. // Phase 1.5 계약: PG 통신 불명 예외(HttpRequestException/TaskCanceledException)는
  79. // 토스가 실제 승인했을 수 있어 Failed 로 단정하지 않고 NeedsReconciliation 으로 표시한다.
  80. using var db = TestDb.Create();
  81. var memberID = await TestDb.CreateMemberAsync(db);
  82. var wallet = Wallet.Create(memberID);
  83. await db.Wallet.AddAsync(wallet);
  84. var order = await TestDb.CreateOrderAsync(db, memberID);
  85. var fake = new FakeTossPaymentService { ConfirmException = new HttpRequestException("network error") };
  86. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  87. var resp = await handler.Handle(new ConfirmCommand("PK-3", order.OrderID, order.Amount, memberID), default);
  88. Assert.IsFalse(resp.Success);
  89. using var verify = TestDb.Create();
  90. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  91. Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "PG 통신 불명은 대사 대상(NeedsReconciliation)으로 기록되어야 한다");
  92. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  93. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "승인 확정 전에는 충전되지 않는다");
  94. var reconcileLog = await verify.PaymentReconcileLog.Where(c => c.PaymentOrderID == order.ID).ToListAsync();
  95. Assert.AreEqual(1, reconcileLog.Count, "자동 대사 배치가 참조할 대사 로그가 독립 커밋되어야 한다");
  96. Assert.AreEqual(ReconcileKind.Confirm, reconcileLog[0].Kind);
  97. }
  98. [TestMethod]
  99. public async Task PgNonCommException_MarksOrderFailed()
  100. {
  101. // 통신 불명이 아닌 예외(설정 오류 등)는 Failed — Pending 방치를 막는다.
  102. using var db = TestDb.Create();
  103. var memberID = await TestDb.CreateMemberAsync(db);
  104. var wallet = Wallet.Create(memberID);
  105. await db.Wallet.AddAsync(wallet);
  106. var order = await TestDb.CreateOrderAsync(db, memberID);
  107. var fake = new FakeTossPaymentService { ConfirmException = new InvalidOperationException("toss config error") };
  108. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  109. var resp = await handler.Handle(new ConfirmCommand("PK-3N", order.OrderID, order.Amount, memberID), default);
  110. Assert.IsFalse(resp.Success);
  111. using var verify = TestDb.Create();
  112. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  113. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status, "통신 불명 이외의 예외는 주문 Failed 로 기록되어야 한다");
  114. }
  115. [TestMethod]
  116. public async Task NeedsReconciliation_ReConfirm_Succeeds_ReusingConfirmRow()
  117. {
  118. // Phase 1.5: 통신 불명(NeedsReconciliation) 주문은 재승인이 허용되어야 하고,
  119. // TossConfirm 은 PaymentKey/OrderID unique 라 기존 요청 행을 재사용해야 한다.
  120. using var db = TestDb.Create();
  121. var memberID = await TestDb.CreateMemberAsync(db);
  122. var wallet = Wallet.Create(memberID);
  123. await db.Wallet.AddAsync(wallet);
  124. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  125. var failFake = new FakeTossPaymentService { ConfirmException = new TaskCanceledException("timeout") };
  126. var handler = new ConfirmHandler(db, failFake, FakeAppDbContextFactory.Instance);
  127. var first = await handler.Handle(new ConfirmCommand("PK-RECON-A", order.OrderID, order.Amount, memberID), default);
  128. Assert.IsFalse(first.Success);
  129. using (var mid = TestDb.Create())
  130. {
  131. var midOrder = await mid.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  132. Assert.AreEqual(PaymentStatus.NeedsReconciliation, midOrder.Status);
  133. }
  134. using var db2 = TestDb.Create();
  135. var handler2 = new ConfirmHandler(db2, new FakeTossPaymentService(), FakeAppDbContextFactory.Instance);
  136. var second = await handler2.Handle(new ConfirmCommand("PK-RECON-B", order.OrderID, order.Amount, memberID), default);
  137. Assert.IsTrue(second.Success, $"NeedsReconciliation 재승인은 성공해야 한다: {second.Message}");
  138. using var verify = TestDb.Create();
  139. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  140. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status);
  141. Assert.AreEqual("PK-RECON-B", savedOrder.TransactionID);
  142. var confirms = await verify.TossConfirm.Where(c => c.OrderID == order.OrderID).ToListAsync();
  143. Assert.AreEqual(1, confirms.Count, "재승인은 기존 TossConfirm 행을 재사용해야 한다 (unique 인덱스 충돌 금지)");
  144. Assert.AreEqual("PK-RECON-B", confirms[0].PaymentKey);
  145. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  146. Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
  147. Assert.AreEqual(1, savedWallet.Transactions.Count(t => t.TxType == WalletTransactionType.Charge && t.RefID == order.OrderID), "충전 원장은 정확히 1건이어야 한다");
  148. }
  149. [TestMethod]
  150. public async Task ConcurrencyConflict_RetriesOnce_WithoutDoubleCredit()
  151. {
  152. // Phase 1.5: PG 승인 성공 후 WalletBalance rowversion 충돌 시 새 컨텍스트로 1회 재적용 —
  153. // 승인금 유실 없이 성공하고, 원장 이중적립이 없어야 한다.
  154. int memberID;
  155. string orderID;
  156. int orderAmount;
  157. using (var setup = TestDb.Create())
  158. {
  159. memberID = await TestDb.CreateMemberAsync(setup);
  160. await setup.Wallet.AddAsync(Wallet.Create(memberID));
  161. var order = await TestDb.CreateOrderAsync(setup, memberID, 10000);
  162. orderID = order.OrderID;
  163. orderAmount = order.Amount;
  164. }
  165. using var db = TestDb.Create();
  166. // 핸들러 컨텍스트에 지갑/잔액을 미리 추적시켜 stale rowversion 상태를 만든다
  167. _ = await db.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  168. // 다른 컨텍스트가 동시 충전 → DB rowversion 전진 (핸들러의 최종 저장이 충돌하게 된다)
  169. using (var other = TestDb.Create())
  170. {
  171. var concurrent = await other.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  172. concurrent.CreditPgCharge(Money.KRW(3000), "동시 충전", "CONCURRENT");
  173. await other.SaveChangesAsync(default);
  174. }
  175. var fake = new FakeTossPaymentService();
  176. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  177. var resp = await handler.Handle(new ConfirmCommand("PK-CONC", orderID, orderAmount, memberID), default);
  178. Assert.IsTrue(resp.Success, $"동시성 충돌은 1회 재시도로 복구되어야 한다: {resp.Message}");
  179. Assert.AreEqual(1, fake.ConfirmCallCount, "재시도는 저장만 다시 한다 — PG 재호출 금지");
  180. using var verify = TestDb.Create();
  181. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == orderID);
  182. Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status);
  183. var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  184. Assert.AreEqual(13000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "동시 충전(3000)과 승인 충전(10000)이 모두 반영되어야 한다");
  185. Assert.AreEqual(1, savedWallet.Transactions.Count(t => t.TxType == WalletTransactionType.Charge && t.RefID == orderID), "원장 이중적립 금지");
  186. }
  187. [TestMethod]
  188. public async Task AlreadyPaid_IsIdempotent_WithoutPgCall()
  189. {
  190. using var db = TestDb.Create();
  191. var memberID = await TestDb.CreateMemberAsync(db);
  192. var order = await TestDb.CreateOrderAsync(db, memberID, 7000, paid: true);
  193. var fake = new FakeTossPaymentService();
  194. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  195. var resp = await handler.Handle(new ConfirmCommand("PK-RETRY", order.OrderID, order.Amount, memberID), default);
  196. Assert.IsTrue(resp.Success);
  197. Assert.AreEqual(7000, resp.PointAmount);
  198. Assert.AreEqual(0, fake.ConfirmCallCount, "이미 완료된 주문은 PG 를 재호출하지 않는다");
  199. }
  200. [TestMethod]
  201. public async Task FailedOrder_ReConfirm_IsRejected_WithoutPgCall_And_NoCrash()
  202. {
  203. // Failed 주문 재진입은 진입 가드가 PG 호출 전에 거절하고, 예외 없이 실패 응답해야 한다.
  204. using var db = TestDb.Create();
  205. var memberID = await TestDb.CreateMemberAsync(db);
  206. var wallet = Wallet.Create(memberID);
  207. await db.Wallet.AddAsync(wallet);
  208. var order = await TestDb.CreateOrderAsync(db, memberID);
  209. order.MarkFailed("이전 승인 실패");
  210. await db.SaveChangesAsync(default);
  211. var fake = new FakeTossPaymentService();
  212. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  213. var resp = await handler.Handle(new ConfirmCommand("PK-RE", order.OrderID, order.Amount, memberID), default);
  214. Assert.IsFalse(resp.Success);
  215. Assert.AreEqual(0, fake.ConfirmCallCount, "종료된 주문은 PG 를 재호출하지 않는다");
  216. using var verify = TestDb.Create();
  217. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  218. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status);
  219. }
  220. [TestMethod]
  221. public async Task MemberMismatch_DoesNotPoisonVictimOrder()
  222. {
  223. // 회원 불일치는 피해자의 Pending 주문 상태를 건드리지 않아야 하고,
  224. // 이후 피해자의 정상 승인이 크래시 없이 성공해야 한다.
  225. using var db = TestDb.Create();
  226. var victimID = await TestDb.CreateMemberAsync(db);
  227. var attackerID = await TestDb.CreateMemberAsync(db);
  228. var wallet = Wallet.Create(victimID);
  229. await db.Wallet.AddAsync(wallet);
  230. var order = await TestDb.CreateOrderAsync(db, victimID, 9000);
  231. var fake = new FakeTossPaymentService();
  232. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  233. var attack = await handler.Handle(new ConfirmCommand("PK-ATK", order.OrderID, order.Amount, attackerID), default);
  234. Assert.IsFalse(attack.Success, "타인의 주문 승인은 거절되어야 한다");
  235. using (var mid = TestDb.Create())
  236. {
  237. var afterAttack = await mid.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  238. Assert.AreEqual(PaymentStatus.Pending, afterAttack.Status, "불일치는 피해자 주문을 오염시키지 않아야 한다");
  239. }
  240. using var db2 = TestDb.Create();
  241. var handler2 = new ConfirmHandler(db2, new FakeTossPaymentService(), FakeAppDbContextFactory.Instance);
  242. var legit = await handler2.Handle(new ConfirmCommand("PK-OK", order.OrderID, order.Amount, victimID), default);
  243. Assert.IsTrue(legit.Success, "피해자의 정상 승인은 크래시 없이 성공해야 한다");
  244. }
  245. [TestMethod]
  246. public async Task PgDeclines_MarksOrderFailed()
  247. {
  248. // 토스의 명시적 거절({code, message})은 불명이 아니므로 Failed 로 종결한다.
  249. using var db = TestDb.Create();
  250. var memberID = await TestDb.CreateMemberAsync(db);
  251. var wallet = Wallet.Create(memberID);
  252. await db.Wallet.AddAsync(wallet);
  253. var order = await TestDb.CreateOrderAsync(db, memberID);
  254. var fake = new FakeTossPaymentService { ConfirmResult = FakeTossPaymentService.Fail() };
  255. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  256. var resp = await handler.Handle(new ConfirmCommand("PK-4", order.OrderID, order.Amount, memberID), default);
  257. Assert.IsFalse(resp.Success);
  258. using var verify = TestDb.Create();
  259. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  260. Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status);
  261. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  262. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "승인 거절 시 충전되지 않는다");
  263. }
  264. [TestMethod]
  265. public async Task WaitingForDeposit_NoCredit_MarksWaitingDeposit()
  266. {
  267. // 가상계좌: 승인 응답 status=WAITING_FOR_DEPOSIT 는 입금 대기 — 이 시점에 지갑 적립 금지.
  268. using var db = TestDb.Create();
  269. var memberID = await TestDb.CreateMemberAsync(db);
  270. var wallet = Wallet.Create(memberID);
  271. await db.Wallet.AddAsync(wallet);
  272. var order = await TestDb.CreateOrderAsync(db, memberID, 10000);
  273. var fake = new FakeTossPaymentService { ConfirmResult = FakeTossPaymentService.WaitingDeposit() };
  274. var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
  275. var resp = await handler.Handle(new ConfirmCommand("PK-VA", order.OrderID, order.Amount, memberID), default);
  276. Assert.IsTrue(resp.Success, "가상계좌 발급은 성공 응답(입금 대기)이다");
  277. Assert.AreEqual(0, resp.PointAmount, "입금 확인 전에는 적립하지 않는다");
  278. using var verify = TestDb.Create();
  279. var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
  280. Assert.AreEqual(PaymentStatus.WaitingDeposit, savedOrder.Status);
  281. Assert.AreEqual("9-4444-0000-1234", savedOrder.VirtualAccountNumber);
  282. var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  283. Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "입금 대기 시점에는 충전 금지");
  284. }
  285. }