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; /// /// 자동 대사 배치 핸들러 — NeedsReconciliation 주문을 조회 API 로 재확인해 종결하는 계약 검증. /// [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, "조회 실패 기록이 남아야 한다"); } }