| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177 |
- 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<InvalidOperationException>(() => 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);
- }
- }
|