| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177 |
- using Domain.Entities.Payments.Toss;
- using Domain.Entities.Payments.Toss.ValueObject;
- using Domain.Entities.Payments.ValueObject;
- using Domain.Entities.Wallets;
- using Domain.Entities.Wallets.ValueObject;
- using Microsoft.EntityFrameworkCore;
- using ReconcileCommand = Application.Features.Api.Payment.Toss.Reconcile.Command;
- using ReconcileHandler = Application.Features.Api.Payment.Toss.Reconcile.Handler;
- namespace Application.Tests;
- /// <summary>
- /// 자동 대사 배치 핸들러 — NeedsReconciliation 주문을 조회 API 로 재확인해 종결하는 계약 검증.
- /// </summary>
- [TestClass]
- public sealed class TossReconcileHandlerTests
- {
- private static async Task<(int MemberID, int OrderID, string OrderNo, int Amount, string PaymentKey)> SeedNeedsReconciliationAsync(bool withConfirmRow = true, bool withWallet = true)
- {
- var paymentKey = $"PK-{Guid.NewGuid():N}";
- using var setup = TestDb.Create();
- var memberID = await TestDb.CreateMemberAsync(setup);
- if (withWallet)
- {
- await setup.Wallet.AddAsync(Wallet.Create(memberID));
- }
- var order = await TestDb.CreateOrderAsync(setup, memberID, 10000);
- order.MarkNeedsReconciliation("승인 통신 불명(타임아웃)");
- if (withConfirmRow)
- {
- await setup.TossConfirm.AddAsync(TossConfirm.CreateRequest(memberID, order.ID, paymentKey, order.OrderID, order.Amount));
- }
- await setup.SaveChangesAsync(default);
- return (memberID, order.ID, order.OrderID, order.Amount, paymentKey);
- }
- [TestMethod]
- public async Task QueryDone_SettlesPaid_AndIsIdempotent()
- {
- // 조회 결과 DONE → 멱등 적립 + Paid 종결. 재실행 시(상태 이탈) 아무것도 하지 않는다.
- var (memberID, orderPk, orderNo, amount, paymentKey) = await SeedNeedsReconciliationAsync();
- var fake = new FakeTossPaymentService { GetPaymentResult = FakeTossPaymentService.Done(paymentKey, orderNo, amount) };
- using (var db = TestDb.Create())
- {
- var handler = new ReconcileHandler(db, fake);
- var result = await handler.Handle(new ReconcileCommand(orderPk), default);
- Assert.IsTrue(result.IsSuccess, "대사 성공 응답이어야 한다");
- }
- using (var mid = TestDb.Create())
- {
- var settled = await mid.PaymentOrder.SingleAsync(o => o.ID == orderPk);
- Assert.AreEqual(PaymentStatus.Paid, settled.Status, "DONE 확인 후 Paid 로 종결되어야 한다");
- Assert.AreEqual(paymentKey, settled.TransactionID);
- var wallet = await mid.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(10000m, wallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "충전 캐시(VAT 제외)가 적립되어야 한다");
- var log = await mid.TossLog.Where(c => c.OrderID == orderNo && c.LogType == TossLogType.Reconcile).ToListAsync();
- Assert.AreEqual(1, log.Count, "대사 성공 로그가 남아야 한다");
- }
- // 재실행 — 이미 Paid 라 멱등 skip, 이중적립 금지
- using (var db2 = TestDb.Create())
- {
- var handler2 = new ReconcileHandler(db2, fake);
- var again = await handler2.Handle(new ReconcileCommand(orderPk), default);
- Assert.IsTrue(again.IsSuccess);
- }
- using var verify = TestDb.Create();
- var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "대사 재실행 시 이중적립 금지");
- Assert.AreEqual(1, savedWallet.Transactions.Count(t => t.TxType == WalletTransactionType.Charge && t.RefID == orderNo));
- }
- [TestMethod]
- public async Task UnknownStatus_NoChange_WritesReconcileLog()
- {
- // 판별 불가 상태(IN_PROGRESS 등) — 아무것도 반영하지 않고 PaymentReconcileLog 만 남긴다.
- var (memberID, orderPk, orderNo, amount, paymentKey) = await SeedNeedsReconciliationAsync();
- var fake = new FakeTossPaymentService
- {
- GetPaymentResult = FakeTossPaymentService.Done(paymentKey, orderNo, amount) with { Status = "IN_PROGRESS" }
- };
- using var db = TestDb.Create();
- var handler = new ReconcileHandler(db, fake);
- var result = await handler.Handle(new ReconcileCommand(orderPk), default);
- Assert.IsTrue(result.IsSuccess);
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.ID == orderPk);
- Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "판별 불가면 상태를 유지해 다음 주기에 재시도한다");
- var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "판별 불가 상태에서는 적립하지 않는다");
- var log = await verify.PaymentReconcileLog.Where(c => c.PaymentOrderID == orderPk).ToListAsync();
- Assert.AreEqual(1, log.Count, "판별 불가 기록이 남아야 한다");
- Assert.AreEqual(ReconcileKind.AutoReconcile, log[0].Kind);
- }
- [TestMethod]
- public async Task QueryCanceled_MarksFailed_WithoutCredit()
- {
- // 적립 전 주문의 종결 상태(CANCELED/ABORTED/EXPIRED) — Failed 처리, 지갑 반영 없음.
- var (memberID, orderPk, orderNo, amount, paymentKey) = await SeedNeedsReconciliationAsync();
- var fake = new FakeTossPaymentService
- {
- GetPaymentResult = FakeTossPaymentService.Done(paymentKey, orderNo, amount) with { Status = "ABORTED" }
- };
- using var db = TestDb.Create();
- var handler = new ReconcileHandler(db, fake);
- var result = await handler.Handle(new ReconcileCommand(orderPk), default);
- Assert.IsTrue(result.IsSuccess);
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.ID == orderPk);
- Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status, "종결 상태는 Failed 로 마감한다");
- var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
- }
- [TestMethod]
- public async Task NonTossOrder_IsSkipped_WithoutPgCall()
- {
- // TossConfirm 행이 없는 주문(=타 PG) — 오처리 방지를 위해 skip.
- var (_, orderPk, _, _, _) = await SeedNeedsReconciliationAsync(withConfirmRow: false);
- var fake = new FakeTossPaymentService();
- using var db = TestDb.Create();
- var handler = new ReconcileHandler(db, fake);
- var result = await handler.Handle(new ReconcileCommand(orderPk), default);
- Assert.IsTrue(result.IsSuccess);
- Assert.AreEqual(0, fake.GetPaymentCallCount, "토스 주문이 아니면 조회 API 를 호출하지 않는다");
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.ID == orderPk);
- Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "타 PG 주문 상태는 건드리지 않는다");
- }
- [TestMethod]
- public async Task QueryCommException_LeavesState_ForNextCycle()
- {
- // 조회 통신 실패 — 아무것도 반영하지 않고 로그만, 다음 주기에 재시도.
- var (_, orderPk, _, _, _) = await SeedNeedsReconciliationAsync();
- var fake = new FakeTossPaymentService { GetPaymentException = new HttpRequestException("timeout") };
- using var db = TestDb.Create();
- var handler = new ReconcileHandler(db, fake);
- var result = await handler.Handle(new ReconcileCommand(orderPk), default);
- Assert.IsTrue(result.IsSuccess, "조회 실패는 배치를 중단시키지 않는다");
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.ID == orderPk);
- Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "통신 실패 시 상태를 유지한다");
- var log = await verify.PaymentReconcileLog.Where(c => c.PaymentOrderID == orderPk).ToListAsync();
- Assert.AreEqual(1, log.Count, "조회 실패 기록이 남아야 한다");
- }
- }
|