| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- using Domain.Entities.Common.ValueObject;
- using Domain.Entities.Payments.Toss.ValueObject;
- using Domain.Entities.Payments.ValueObject;
- using Domain.Entities.Wallets;
- using Domain.Entities.Wallets.ValueObject;
- using Microsoft.EntityFrameworkCore;
- using ConfirmCommand = Application.Features.Api.Payment.Toss.Confirm.Command;
- using ConfirmHandler = Application.Features.Api.Payment.Toss.Confirm.Handler;
- namespace Application.Tests;
- [TestClass]
- public sealed class TossConfirmHandlerTests
- {
- [TestMethod]
- public async Task Success_CreditsPgCharged_And_MarksPaid()
- {
- 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, 10000);
- var fake = new FakeTossPaymentService();
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-1", order.OrderID, order.Amount, memberID), default);
- Assert.IsTrue(resp.Success, $"승인이 성공해야 한다: {resp.Message}");
- Assert.AreEqual(10000, resp.PointAmount);
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status);
- Assert.AreEqual("PK-1", savedOrder.TransactionID, "MarkPaid 시 paymentKey 가 TransactionID 에 저장되어야 한다");
- var savedConfirm = await verify.TossConfirm.SingleAsync(c => c.OrderID == order.OrderID);
- Assert.AreEqual("DONE", savedConfirm.Status);
- Assert.IsTrue(savedConfirm.IsSuccess);
- 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.IsTrue(savedWallet.Transactions.Any(t => t.TxType == WalletTransactionType.Charge && t.Amount.Value == 10000m && t.RefID == order.OrderID));
- }
- [TestMethod]
- public async Task AmountMismatch_Fails_BeforePgCall()
- {
- // Toss 계약: 클라이언트 전달 amount ≠ 서버 저장 주문 금액이면 PG 호출 전에 즉시 실패 (변조 차단)
- 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, 10000);
- var fake = new FakeTossPaymentService();
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-TAMPER", order.OrderID, order.Amount - 1000, memberID), default);
- Assert.IsFalse(resp.Success);
- Assert.AreEqual(0, fake.ConfirmCallCount, "금액 불일치는 PG 호출 전에 거절되어야 한다");
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.Pending, savedOrder.Status, "금액 불일치는 주문 상태를 종결하지 않는다 (정상 재시도 허용)");
- var log = await verify.TossLog.Where(c => c.OrderID == order.OrderID && c.Code == "AMOUNT_MISMATCH").ToListAsync();
- Assert.AreEqual(1, log.Count, "AMOUNT_MISMATCH 로그가 남아야 한다");
- }
- [TestMethod]
- public async Task WalletMissing_CreatesWallet_And_PersistsPaid()
- {
- // PG 승인 성공 후 지갑이 없어도 MarkPaid 가 유실되지 않아야 한다 — 지갑을 생성해 반영.
- using var db = TestDb.Create();
- var memberID = await TestDb.CreateMemberAsync(db);
- var order = await TestDb.CreateOrderAsync(db, memberID, 5000);
- var fake = new FakeTossPaymentService();
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-2", order.OrderID, order.Amount, memberID), default);
- Assert.IsTrue(resp.Success, $"승인 성공이 반영되어야 한다: {resp.Message}");
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status, "PG 승인 성공이 DB에 기록되어야 한다");
- var savedWallet = await verify.Wallet.Include(w => w.Balances).AsSplitQuery().SingleOrDefaultAsync(w => w.MemberID == memberID);
- Assert.IsNotNull(savedWallet, "지갑이 자동 생성되어야 한다");
- Assert.AreEqual(5000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value);
- }
- [TestMethod]
- public async Task PgCommException_MarksNeedsReconciliation()
- {
- // Phase 1.5 계약: PG 통신 불명 예외(HttpRequestException/TaskCanceledException)는
- // 토스가 실제 승인했을 수 있어 Failed 로 단정하지 않고 NeedsReconciliation 으로 표시한다.
- 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 { ConfirmException = new HttpRequestException("network error") };
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-3", order.OrderID, order.Amount, memberID), default);
- Assert.IsFalse(resp.Success);
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.NeedsReconciliation, savedOrder.Status, "PG 통신 불명은 대사 대상(NeedsReconciliation)으로 기록되어야 한다");
- 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 reconcileLog = await verify.PaymentReconcileLog.Where(c => c.PaymentOrderID == order.ID).ToListAsync();
- Assert.AreEqual(1, reconcileLog.Count, "자동 대사 배치가 참조할 대사 로그가 독립 커밋되어야 한다");
- Assert.AreEqual(ReconcileKind.Confirm, reconcileLog[0].Kind);
- }
- [TestMethod]
- public async Task PgNonCommException_MarksOrderFailed()
- {
- // 통신 불명이 아닌 예외(설정 오류 등)는 Failed — Pending 방치를 막는다.
- 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 { ConfirmException = new InvalidOperationException("toss config error") };
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-3N", order.OrderID, order.Amount, memberID), default);
- Assert.IsFalse(resp.Success);
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status, "통신 불명 이외의 예외는 주문 Failed 로 기록되어야 한다");
- }
- [TestMethod]
- public async Task NeedsReconciliation_ReConfirm_Succeeds_ReusingConfirmRow()
- {
- // Phase 1.5: 통신 불명(NeedsReconciliation) 주문은 재승인이 허용되어야 하고,
- // TossConfirm 은 PaymentKey/OrderID unique 라 기존 요청 행을 재사용해야 한다.
- 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, 10000);
- var failFake = new FakeTossPaymentService { ConfirmException = new TaskCanceledException("timeout") };
- var handler = new ConfirmHandler(db, failFake, FakeAppDbContextFactory.Instance);
- var first = await handler.Handle(new ConfirmCommand("PK-RECON-A", order.OrderID, order.Amount, memberID), default);
- Assert.IsFalse(first.Success);
- using (var mid = TestDb.Create())
- {
- var midOrder = await mid.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.NeedsReconciliation, midOrder.Status);
- }
- using var db2 = TestDb.Create();
- var handler2 = new ConfirmHandler(db2, new FakeTossPaymentService(), FakeAppDbContextFactory.Instance);
- var second = await handler2.Handle(new ConfirmCommand("PK-RECON-B", order.OrderID, order.Amount, memberID), default);
- Assert.IsTrue(second.Success, $"NeedsReconciliation 재승인은 성공해야 한다: {second.Message}");
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.Paid, savedOrder.Status);
- Assert.AreEqual("PK-RECON-B", savedOrder.TransactionID);
- var confirms = await verify.TossConfirm.Where(c => c.OrderID == order.OrderID).ToListAsync();
- Assert.AreEqual(1, confirms.Count, "재승인은 기존 TossConfirm 행을 재사용해야 한다 (unique 인덱스 충돌 금지)");
- Assert.AreEqual("PK-RECON-B", confirms[0].PaymentKey);
- 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 == order.OrderID), "충전 원장은 정확히 1건이어야 한다");
- }
- [TestMethod]
- public async Task ConcurrencyConflict_RetriesOnce_WithoutDoubleCredit()
- {
- // Phase 1.5: PG 승인 성공 후 WalletBalance rowversion 충돌 시 새 컨텍스트로 1회 재적용 —
- // 승인금 유실 없이 성공하고, 원장 이중적립이 없어야 한다.
- int memberID;
- string orderID;
- int orderAmount;
- using (var setup = TestDb.Create())
- {
- memberID = await TestDb.CreateMemberAsync(setup);
- await setup.Wallet.AddAsync(Wallet.Create(memberID));
- var order = await TestDb.CreateOrderAsync(setup, memberID, 10000);
- orderID = order.OrderID;
- orderAmount = order.Amount;
- }
- using var db = TestDb.Create();
- // 핸들러 컨텍스트에 지갑/잔액을 미리 추적시켜 stale rowversion 상태를 만든다
- _ = await db.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- // 다른 컨텍스트가 동시 충전 → DB rowversion 전진 (핸들러의 최종 저장이 충돌하게 된다)
- using (var other = TestDb.Create())
- {
- var concurrent = await other.Wallet.Include(w => w.Balances).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- concurrent.CreditPgCharge(Money.KRW(3000), "동시 충전", "CONCURRENT");
- await other.SaveChangesAsync(default);
- }
- var fake = new FakeTossPaymentService();
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-CONC", orderID, orderAmount, memberID), default);
- Assert.IsTrue(resp.Success, $"동시성 충돌은 1회 재시도로 복구되어야 한다: {resp.Message}");
- Assert.AreEqual(1, fake.ConfirmCallCount, "재시도는 저장만 다시 한다 — PG 재호출 금지");
- using var verify = TestDb.Create();
- 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).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(13000m, savedWallet.Balances.Single(b => b.Type == WalletBalanceType.PgCharged).Amount.Value, "동시 충전(3000)과 승인 충전(10000)이 모두 반영되어야 한다");
- Assert.AreEqual(1, savedWallet.Transactions.Count(t => t.TxType == WalletTransactionType.Charge && t.RefID == orderID), "원장 이중적립 금지");
- }
- [TestMethod]
- public async Task AlreadyPaid_IsIdempotent_WithoutPgCall()
- {
- using var db = TestDb.Create();
- var memberID = await TestDb.CreateMemberAsync(db);
- var order = await TestDb.CreateOrderAsync(db, memberID, 7000, paid: true);
- var fake = new FakeTossPaymentService();
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-RETRY", order.OrderID, order.Amount, memberID), default);
- Assert.IsTrue(resp.Success);
- Assert.AreEqual(7000, resp.PointAmount);
- Assert.AreEqual(0, fake.ConfirmCallCount, "이미 완료된 주문은 PG 를 재호출하지 않는다");
- }
- [TestMethod]
- public async Task FailedOrder_ReConfirm_IsRejected_WithoutPgCall_And_NoCrash()
- {
- // Failed 주문 재진입은 진입 가드가 PG 호출 전에 거절하고, 예외 없이 실패 응답해야 한다.
- 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);
- order.MarkFailed("이전 승인 실패");
- await db.SaveChangesAsync(default);
- var fake = new FakeTossPaymentService();
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-RE", order.OrderID, order.Amount, memberID), default);
- Assert.IsFalse(resp.Success);
- Assert.AreEqual(0, fake.ConfirmCallCount, "종료된 주문은 PG 를 재호출하지 않는다");
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.Failed, savedOrder.Status);
- }
- [TestMethod]
- public async Task MemberMismatch_DoesNotPoisonVictimOrder()
- {
- // 회원 불일치는 피해자의 Pending 주문 상태를 건드리지 않아야 하고,
- // 이후 피해자의 정상 승인이 크래시 없이 성공해야 한다.
- using var db = TestDb.Create();
- var victimID = await TestDb.CreateMemberAsync(db);
- var attackerID = await TestDb.CreateMemberAsync(db);
- var wallet = Wallet.Create(victimID);
- await db.Wallet.AddAsync(wallet);
- var order = await TestDb.CreateOrderAsync(db, victimID, 9000);
- var fake = new FakeTossPaymentService();
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var attack = await handler.Handle(new ConfirmCommand("PK-ATK", order.OrderID, order.Amount, attackerID), default);
- Assert.IsFalse(attack.Success, "타인의 주문 승인은 거절되어야 한다");
- using (var mid = TestDb.Create())
- {
- var afterAttack = await mid.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.Pending, afterAttack.Status, "불일치는 피해자 주문을 오염시키지 않아야 한다");
- }
- using var db2 = TestDb.Create();
- var handler2 = new ConfirmHandler(db2, new FakeTossPaymentService(), FakeAppDbContextFactory.Instance);
- var legit = await handler2.Handle(new ConfirmCommand("PK-OK", order.OrderID, order.Amount, victimID), default);
- Assert.IsTrue(legit.Success, "피해자의 정상 승인은 크래시 없이 성공해야 한다");
- }
- [TestMethod]
- public async Task PgDeclines_MarksOrderFailed()
- {
- // 토스의 명시적 거절({code, message})은 불명이 아니므로 Failed 로 종결한다.
- 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 { ConfirmResult = FakeTossPaymentService.Fail() };
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-4", order.OrderID, order.Amount, memberID), default);
- Assert.IsFalse(resp.Success);
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.Failed, 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, "승인 거절 시 충전되지 않는다");
- }
- [TestMethod]
- public async Task WaitingForDeposit_NoCredit_MarksWaitingDeposit()
- {
- // 가상계좌: 승인 응답 status=WAITING_FOR_DEPOSIT 는 입금 대기 — 이 시점에 지갑 적립 금지.
- 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, 10000);
- var fake = new FakeTossPaymentService { ConfirmResult = FakeTossPaymentService.WaitingDeposit() };
- var handler = new ConfirmHandler(db, fake, FakeAppDbContextFactory.Instance);
- var resp = await handler.Handle(new ConfirmCommand("PK-VA", order.OrderID, order.Amount, memberID), default);
- Assert.IsTrue(resp.Success, "가상계좌 발급은 성공 응답(입금 대기)이다");
- Assert.AreEqual(0, resp.PointAmount, "입금 확인 전에는 적립하지 않는다");
- using var verify = TestDb.Create();
- var savedOrder = await verify.PaymentOrder.SingleAsync(o => o.OrderID == order.OrderID);
- Assert.AreEqual(PaymentStatus.WaitingDeposit, savedOrder.Status);
- Assert.AreEqual("9-4444-0000-1234", savedOrder.VirtualAccountNumber);
- 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, "입금 대기 시점에는 충전 금지");
- }
- }
|