using Domain.Entities.Common.ValueObject; using Domain.Entities.Payments.ValueObject; using Domain.Entities.Wallets; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; using ConfirmCommand = Application.Features.Api.Payment.Confirm.Command; using ConfirmHandler = Application.Features.Api.Payment.Confirm.Handler; namespace Application.Tests; [TestClass] public sealed class PaymentConfirmHandlerTests { [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 FakeDanalPayService(); var handler = new ConfirmHandler(db, fake); var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-1", "CARD", memberID), default); Assert.IsTrue(resp.Success); 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("TX-1", savedOrder.TransactionID); 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 WalletMissing_CreatesWallet_And_PersistsPaid() { // P0 #1 재현: PG 승인 성공 후 지갑이 없으면 MarkPaid 가 저장되지 않고 유실됐다. // 기대 행위: 지갑을 생성해 충전을 반영하고 주문을 Paid 로 저장한다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var order = await TestDb.CreateOrderAsync(db, memberID, 5000); var fake = new FakeDanalPayService(); var handler = new ConfirmHandler(db, fake); var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-2", "CARD", 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 PgThrows_MarksOrderFailed() { // P0 #2 재현: PG 호출 예외 시 주문이 Pending 으로 방치됐다. // 기대 행위: 주문을 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 FakeDanalPayService { ConfirmException = new HttpRequestException("network error") }; var handler = new ConfirmHandler(db, fake); var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-3", "CARD", 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, "PG 통신 오류는 주문 Failed 로 기록되어야 한다"); } [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 FakeDanalPayService(); var handler = new ConfirmHandler(db, fake); var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-RETRY", "CARD", 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() { // 리뷰 #1/#9 재현: Failed 주문이 재진입하면 PG 재호출 후 MarkPaid 가 예외를 던져 승인금이 유실됐다. // 기대 행위: 진입 가드가 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 FakeDanalPayService(); var handler = new ConfirmHandler(db, fake); var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-RE", "CARD", 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() { // 리뷰 #1 공격벡터 재현: 회원 불일치 분기가 피해자의 Pending 주문을 Failed 로 저장해 // 이후 정상 승인을 MarkPaid 크래시로 유도할 수 있었다. // 기대 행위: 불일치는 주문 상태를 건드리지 않고, 피해자의 정상 승인이 뒤이어 성공한다. 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 FakeDanalPayService(); var handler = new ConfirmHandler(db, fake); var attack = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-ATK", "CARD", 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 FakeDanalPayService()); var legit = await handler2.Handle(new ConfirmCommand(order.OrderID, "TX-OK", "CARD", victimID), default); Assert.IsTrue(legit.Success, "피해자의 정상 승인은 크래시 없이 성공해야 한다"); } [TestMethod] public async Task PgDeclines_MarksOrderFailed() { 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 FakeDanalPayService { ConfirmResult = FakeDanalPayService.FailConfirm() }; var handler = new ConfirmHandler(db, fake); var resp = await handler.Handle(new ConfirmCommand(order.OrderID, "TX-4", "CARD", 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, "승인 거절 시 충전되지 않는다"); } }