using Domain.Entities.Common.ValueObject; using Domain.Entities.Payments.ValueObject; using Domain.Entities.Wallets; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; using CancelCommand = Application.Features.Api.Payment.Toss.Cancel.Command; using CancelHandler = Application.Features.Api.Payment.Toss.Cancel.Handler; namespace Application.Tests; [TestClass] public sealed class TossCancelHandlerTests { [TestMethod] public async Task Cancel_RefundsPgCharged_WithChargeCancelledTx() { // 기대 행위: PgCharged 에서 회수하고 ChargeCancelled 트랜잭션을 기록한다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var wallet = Wallet.Create(memberID); wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED"); await db.Wallet.AddAsync(wallet); var order = await TestDb.CreateOrderAsync(db, memberID, 10000, paid: true); var fake = new FakeTossPaymentService(); var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance); var result = await handler.Handle(new CancelCommand(order.OrderID, null, memberID), default); Assert.IsTrue(result.IsSuccess, $"취소가 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Description)}"); Assert.AreEqual(1, fake.CancelCallCount); using var verify = TestDb.Create(); var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID); Assert.AreEqual(PaymentStatus.Cancelled, savedOrder.Status); var savedCancel = await verify.TossCancel.SingleAsync(c => c.OrderID == order.OrderID); Assert.IsTrue(savedCancel.IsSuccess, "취소 성공 응답이 기록되어야 한다"); Assert.AreEqual("CANCELED", savedCancel.Status); var savedWallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID); Assert.AreEqual(0m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "환불은 PgCharged 에서 회수한다"); Assert.IsTrue(savedWallet.Transactions.Any(t => t.TxType == WalletTransactionType.ChargeCancelled && t.Amount.Value == 10000m && t.RefID == order.OrderID), "ChargeCancelled 원장 기록이 있어야 한다"); } [TestMethod] public async Task PointsAlreadySpent_Fails_BeforePgCall() { // 기대 행위: 충전 캐시를 이미 사용했다면 PG 호출 전에 거절한다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var wallet = Wallet.Create(memberID); wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED"); wallet.Spend(Money.KRW(8000), "사용"); await db.Wallet.AddAsync(wallet); var order = await TestDb.CreateOrderAsync(db, memberID, 10000, paid: true); var fake = new FakeTossPaymentService(); var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance); var result = await handler.Handle(new CancelCommand(order.OrderID, null, memberID), default); Assert.IsFalse(result.IsSuccess, "사용분이 있으면 취소를 거절해야 한다"); Assert.AreEqual(0, fake.CancelCallCount, "거절은 PG 호출 전에 이뤄져야 한다"); using var verify = TestDb.Create(); var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID); Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "주문 상태는 그대로 Paid 여야 한다"); } [TestMethod] public async Task WalletMissing_Fails_BeforePgCall() { // 지갑이 없으면 PG 호출 전에 거절한다 (PG 환불 + 캐시 유지 desync 방지). using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var order = await TestDb.CreateOrderAsync(db, memberID, 10000, paid: true); var fake = new FakeTossPaymentService(); var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance); var result = await handler.Handle(new CancelCommand(order.OrderID, null, memberID), default); Assert.IsFalse(result.IsSuccess, "지갑이 없으면 취소를 거절해야 한다"); Assert.AreEqual(0, fake.CancelCallCount, "거절은 PG 호출 전에 이뤄져야 한다"); } [TestMethod] public async Task PgCommException_LeavesOrderPaid_And_WalletUntouched() { // PG 취소 통신 예외 시 지갑 무변경 + 주문 Paid 유지 (선차감 회귀 방지). using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var wallet = Wallet.Create(memberID); wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED"); await db.Wallet.AddAsync(wallet); var order = await TestDb.CreateOrderAsync(db, memberID, 10000, paid: true); var fake = new FakeTossPaymentService { CancelException = new HttpRequestException("timeout") }; var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance); var result = await handler.Handle(new CancelCommand(order.OrderID, null, memberID), default); Assert.IsFalse(result.IsSuccess); using var verify = TestDb.Create(); var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID); Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "PG 취소 실패 시 주문은 Paid 로 유지"); var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID); Assert.AreEqual(10000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "PG 미취소 시 캐시는 차감되지 않아야 한다"); } [TestMethod] public async Task ConflictThenPointsSpent_WritesReconcileLog_AndThrows() { // Phase 1.5: PG 취소 성공 후 저장이 동시성 충돌로 실패했고, 그 사이 캐시가 사용돼 // 재시도(리로드 재적용)도 불가능한 경우 — PG 는 이미 환불됐으므로 자동 복구 불가. // 기대 행위: PaymentReconcileLog 를 독립 커밋으로 남기고 예외를 전파한다(기존 실패 흐름 유지). int memberID; string orderID; int orderPk; using (var setup = TestDb.Create()) { memberID = await TestDb.CreateMemberAsync(setup); var wallet = Wallet.Create(memberID); wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED"); await setup.Wallet.AddAsync(wallet); var order = await TestDb.CreateOrderAsync(setup, memberID, 10000, paid: true); orderID = order.OrderID; orderPk = order.ID; } using var db = TestDb.Create(); // 핸들러 컨텍스트에 지갑/잔액을 미리 추적시켜 stale rowversion 상태를 만든다 // (선검증은 stale 잔액 10000 을 보고 통과 → PG 취소 성공 → 저장에서 충돌) _ = await db.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID); using (var other = TestDb.Create()) { var concurrent = await other.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID); concurrent.Spend(Money.KRW(8000), "동시 사용"); await other.SaveChangesAsync(default); } var fake = new FakeTossPaymentService(); var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance); await Assert.ThrowsExceptionAsync(() => handler.Handle(new CancelCommand(orderID, null, memberID), default)); Assert.AreEqual(1, fake.CancelCallCount, "PG 취소는 1회만 호출되어야 한다"); using var verify = TestDb.Create(); var log = await verify.PaymentReconcileLog.SingleOrDefaultAsync(l => l.PaymentOrderID == orderPk); Assert.IsNotNull(log, "대사 로그가 독립 커밋으로 남아야 한다"); Assert.AreEqual(ReconcileKind.Cancel, log.Kind); Assert.AreEqual(10000, log.Amount); var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == orderID); Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "재시도 실패 시 로컬 상태는 미변경(수동 대사 대상)이어야 한다"); var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID); Assert.AreEqual(2000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "동시 사용 반영 외 지갑 변경이 없어야 한다"); } [TestMethod] public async Task NotPaidOrder_Fails() { using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var wallet = Wallet.Create(memberID); await db.Wallet.AddAsync(wallet); var order = await TestDb.CreateOrderAsync(db, memberID); var fake = new FakeTossPaymentService(); var handler = new CancelHandler(db, fake, FakeAppDbContextFactory.Instance); var result = await handler.Handle(new CancelCommand(order.OrderID, null, memberID), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual(0, fake.CancelCallCount); } }