package payment import ( "context" "encoding/json" "testing" "time" "hfb_sys/backend/internal/model" ordermodule "hfb_sys/backend/internal/modules/order" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) // setupPaymentTestDB 创建测试数据库 func setupPaymentTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("无法创建测试数据库: %v", err) } if err := db.AutoMigrate( &model.PaymentOrder{}, &model.RentalOrder{}, &model.RentalListing{}, &model.GameAccount{}, &model.User{}, &model.ChatConversation{}, &model.ChatParticipant{}, &model.ChatMessage{}, &model.Notification{}, ); err != nil { t.Fatalf("数据库迁移失败: %v", err) } return db } type payableOrderFixture struct { Owner model.User Renter model.User Account model.GameAccount Listing model.RentalListing Order model.RentalOrder Payment model.PaymentOrder } func createPayableOrderFixture(t *testing.T, db *gorm.DB, suffix string, createPayment bool) payableOrderFixture { t.Helper() f := payableOrderFixture{ Owner: model.User{Phone: "13800001" + suffix}, Renter: model.User{Phone: "13900001" + suffix}, } if err := db.Create(&f.Owner).Error; err != nil { t.Fatalf("create owner failed: %v", err) } if err := db.Create(&f.Renter).Error; err != nil { t.Fatalf("create renter failed: %v", err) } f.Account = model.GameAccount{ OwnerID: f.Owner.ID, ServerRegion: "国服", LoginPlatform: "steam", Title: "测试账号", Status: "published", } if err := db.Create(&f.Account).Error; err != nil { t.Fatalf("create account failed: %v", err) } f.Listing = model.RentalListing{ ListingNo: "LST20260614" + suffix, OwnerID: f.Owner.ID, AccountID: f.Account.ID, Status: "published", ReviewStatus: "approved", InTransaction: true, PriceCent: 1000, } if err := db.Create(&f.Listing).Error; err != nil { t.Fatalf("create listing failed: %v", err) } f.Order = model.RentalOrder{ OrderNo: "ORD20260614" + suffix, ListingID: f.Listing.ID, AccountID: f.Account.ID, OwnerID: f.Owner.ID, RenterID: f.Renter.ID, RentAmountCent: 1000, Status: "pending_payment", HandoffStatus: "none", } if err := db.Create(&f.Order).Error; err != nil { t.Fatalf("create order failed: %v", err) } f.Payment = model.PaymentOrder{ PaymentNo: "PAY20260614" + suffix, OrderID: f.Order.ID, OrderNo: f.Order.OrderNo, UserID: f.Renter.ID, Provider: "mock", ThirdOrderID: "PAY20260614" + suffix, ProviderOrderID: "MOCKPAY20260614" + suffix, AmountCent: 1000, BizType: "order_pay", Status: "paying", } if createPayment { if err := db.Create(&f.Payment).Error; err != nil { t.Fatalf("create payment failed: %v", err) } } return f } func TestMarkRefundFailedPersistsRawRequest(t *testing.T) { db := setupPaymentTestDB(t) f := createPayableOrderFixture(t, db, "RAWREQ", true) refund := model.PaymentOrder{ PaymentNo: "PAYREFRAWREQ", OrderID: f.Order.ID, OrderNo: f.Order.OrderNo, UserID: f.Renter.ID, Provider: "lakala", MerchantID: "merchant-1", ThirdOrderID: "REFRAWREQ", ProviderOrderID: "", AmountCent: 99, BizType: "checkout_refund", Status: "refunding", } if err := db.Create(&refund).Error; err != nil { t.Fatalf("create refund payment failed: %v", err) } repo := NewRepository(db, nil, nil) rawRequest := map[string]string{ "merchant_no": "merchant-1", "out_trade_no": "REFRAWREQ", "refund_amount": "99", "origin_trade_no": "TRADE123", } err := repo.markRefundFailed(context.Background(), refund.ID, f.Order.ID, refund.AmountCent, map[string]string{"error": "lakala http status 400"}, rawRequest) if err != nil { t.Fatalf("mark refund failed: %v", err) } var saved model.PaymentOrder if err := db.First(&saved, refund.ID).Error; err != nil { t.Fatalf("find refund payment failed: %v", err) } var got map[string]string if err := json.Unmarshal(saved.RawRequest, &got); err != nil { t.Fatalf("decode raw_request: %v", err) } for key, want := range rawRequest { if got[key] != want { t.Fatalf("raw_request[%s] = %q, want %q; full=%v", key, got[key], want, got) } } } // TestCanReusePaymentWithSameProvider 测试相同渠道可复用 func TestCanReusePaymentWithSameProvider(t *testing.T) { payment := model.PaymentOrder{ Status: "paying", Provider: "lakala", MerchantID: "M123", } config := runtimePaymentConfig{ Provider: "lakala", MerchantID: "M123", } if !canReuseOrderPayment(payment, config) { t.Fatal("should reuse payment with same provider and merchant") } } // TestCannotReusePaymentWithDifferentProvider 测试不同渠道不可复用 func TestCannotReusePaymentWithDifferentProvider(t *testing.T) { payment := model.PaymentOrder{ Status: "paying", Provider: "lakala", MerchantID: "M123", } config := runtimePaymentConfig{ Provider: "leshua", // 不同渠道 MerchantID: "M456", } if canReuseOrderPayment(payment, config) { t.Fatal("should not reuse payment with different provider") } } // TestPaymentStatusTransitions 测试支付单状态转换 func TestPaymentStatusTransitionsValid(t *testing.T) { validTransitions := map[string][]string{ "pending": {"paying", "closed"}, "paying": {"paid", "failed", "closed"}, "paid": {"refunding", "refunded"}, "failed": {}, // 终态 "closed": {}, // 终态 "refunded": {}, // 终态 } for from, toList := range validTransitions { if from == "" { t.Fatal("payment status should not be empty") } for _, to := range toList { if to == "" { t.Fatal("transition target should not be empty") } } } } // TestRefundBizTypeCoverage 测试退款业务类型覆盖 func TestRefundBizTypeCoverage(t *testing.T) { expected := []string{ "cancel_refund", "admin_close_refund", "admin_refund", "checkout_refund", "deposit_refund", "rent_refund", "arbitration_refund", } if len(refundBizTypes) != len(expected) { t.Fatalf("refundBizTypes count = %d, want %d", len(refundBizTypes), len(expected)) } // 验证每个预期类型都存在 typeMap := make(map[string]bool) for _, bizType := range refundBizTypes { typeMap[bizType] = true } for _, exp := range expected { if !typeMap[exp] { t.Fatalf("missing refund biz type: %s", exp) } } } // TestPaymentAmountValidation 测试支付金额验证 func TestPaymentAmountMustBePositive(t *testing.T) { testCases := []struct { name string amount int64 wantValid bool }{ {"正数金额", 10000, true}, {"零金额", 0, false}, {"负数金额", -1000, false}, {"最小金额", 1, true}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { isValid := tc.amount > 0 if isValid != tc.wantValid { t.Fatalf("amount %d validation = %v, want %v", tc.amount, isValid, tc.wantValid) } }) } } // TestPaymentProviderValidation 测试支付渠道验证 func TestPaymentProviderMustNotBeEmpty(t *testing.T) { testCases := []struct { name string provider string wantValid bool }{ {"lakala 渠道", "lakala", true}, {"leshua 渠道", "leshua", true}, {"mock 渠道", "mock", true}, {"空渠道", "", false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { isValid := tc.provider != "" if isValid != tc.wantValid { t.Fatalf("provider %q validation = %v, want %v", tc.provider, isValid, tc.wantValid) } }) } } // TestRuntimeConfigIsMockMode 测试运行时配置 mock 模式 func TestRuntimeConfigIsMockModeTrue(t *testing.T) { config := runtimePaymentConfig{ Provider: "mock", } if !config.isMockMode() { t.Fatal("should be mock mode when provider is 'mock'") } } func TestRuntimeConfigIsMockModeFalse(t *testing.T) { providers := []string{"lakala", "leshua", "alipay", "wechat"} for _, provider := range providers { config := runtimePaymentConfig{ Provider: provider, } if config.isMockMode() { t.Fatalf("should not be mock mode when provider is %q", provider) } } } // TestChannelSourceValidation 测试渠道来源验证 func TestChannelSourceMustBeValid(t *testing.T) { validSources := []string{ channelSourceCreate, channelSourceQuery, channelSourceNotify, channelSourceMock, } expectedValues := []string{"create", "query", "notify", "mock"} for i, source := range validSources { if source != expectedValues[i] { t.Fatalf("channelSource[%d] = %q, want %q", i, source, expectedValues[i]) } } } // TestPaymentOrderFields 测试支付单字段完整性 func TestPaymentOrderRequiredFields(t *testing.T) { payment := model.PaymentOrder{ PaymentNo: "PAY123456", OrderNo: "ORD123456", OrderID: 100, UserID: 1, AmountCent: 10000, Status: "paying", Provider: "lakala", MerchantID: "M123", BizType: "order_pay", } // 验证必填字段 if payment.PaymentNo == "" { t.Fatal("PaymentNo should not be empty") } if payment.OrderNo == "" { t.Fatal("OrderNo should not be empty") } if payment.OrderID == 0 { t.Fatal("OrderID should not be zero") } if payment.UserID == 0 { t.Fatal("UserID should not be zero") } if payment.AmountCent <= 0 { t.Fatal("AmountCent should be positive") } if payment.Status == "" { t.Fatal("Status should not be empty") } if payment.Provider == "" { t.Fatal("Provider should not be empty") } } // TestRefundAmountValidation 测试退款金额验证 func TestRefundAmountMustNotExceedOriginal(t *testing.T) { testCases := []struct { name string originalAmount int64 refundAmount int64 wantValid bool }{ {"部分退款", 10000, 5000, true}, {"全额退款", 10000, 10000, true}, {"超额退款", 10000, 15000, false}, {"零退款", 10000, 0, false}, {"负数退款", 10000, -1000, false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { isValid := tc.refundAmount > 0 && tc.refundAmount <= tc.originalAmount if isValid != tc.wantValid { t.Fatalf("refund %d from %d validation = %v, want %v", tc.refundAmount, tc.originalAmount, isValid, tc.wantValid) } }) } } // TestPaymentNotifyResultStructure 测试支付回调结果结构 func TestNotifyResultHasRequiredFields(t *testing.T) { result := NotifyResult{ OK: true, Message: "payment success", } if result.Message == "" { t.Fatal("NotifyResult.Message should not be empty") } } func TestStartRefundDoesNotCreateNewOrderWhenFailedRefundExists(t *testing.T) { db := setupPaymentTestDB(t) repo := NewRepository(db, nil, nil) order := model.RentalOrder{ ID: 1001, OrderNo: "ORD202606140001", RenterID: 11, RentAmountCent: 800, DepositAmountCent: 200, RefundStatus: "failed", RefundAmountCent: 1000, Status: "closed", } if err := db.Create(&order).Error; err != nil { t.Fatalf("create order failed: %v", err) } original := model.PaymentOrder{ PaymentNo: "PAY202606140001", OrderID: order.ID, OrderNo: order.OrderNo, UserID: order.RenterID, Provider: "mock", ThirdOrderID: "PAY202606140001", ProviderOrderID: "MOCKPAY202606140001", AmountCent: 1000, BizType: "order_pay", Status: "paid", } if err := db.Create(&original).Error; err != nil { t.Fatalf("create original payment failed: %v", err) } existingRefund := model.PaymentOrder{ PaymentNo: "PAY202606140002", OrderID: order.ID, OrderNo: order.OrderNo, UserID: order.RenterID, Provider: "mock", ThirdOrderID: "REF202606140002", AmountCent: 1000, BizType: "admin_refund", Status: "failed", } if err := db.Create(&existingRefund).Error; err != nil { t.Fatalf("create existing refund failed: %v", err) } dto, err := repo.StartRefund(t.Context(), order.ID, 1000, "admin_refund", "后台人工退款") if err != nil { t.Fatalf("StartRefund() error = %v", err) } if dto.ID != existingRefund.ID { t.Fatalf("StartRefund() returned payment id %d, want existing %d", dto.ID, existingRefund.ID) } var count int64 if err := db.Model(&model.PaymentOrder{}). Where("order_id = ? AND biz_type = ?", order.ID, "admin_refund"). Count(&count).Error; err != nil { t.Fatalf("count refund orders failed: %v", err) } if count != 1 { t.Fatalf("refund order count = %d, want 1", count) } } func TestConfirmPaidUpdatesOrderAndPaymentInOnePath(t *testing.T) { db := setupPaymentTestDB(t) orderRepo := ordermodule.NewRepository(db) repo := NewRepository(db, nil, orderRepo) fixture := createPayableOrderFixture(t, db, "0010", true) paidAt := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt, map[string]string{ "provider_order_id": "PROVIDER202606140010", }, channelSourceNotify) if err != nil { t.Fatalf("confirmPaid() error = %v", err) } var latestPayment model.PaymentOrder if err := db.First(&latestPayment, fixture.Payment.ID).Error; err != nil { t.Fatalf("find payment failed: %v", err) } if latestPayment.Status != "paid" { t.Fatalf("payment status = %q, want paid", latestPayment.Status) } if latestPayment.ProviderOrderID != "PROVIDER202606140010" { t.Fatalf("provider order id = %q, want provider response", latestPayment.ProviderOrderID) } var latestOrder model.RentalOrder if err := db.First(&latestOrder, fixture.Order.ID).Error; err != nil { t.Fatalf("find order failed: %v", err) } if latestOrder.Status != "pending_handoff" || latestOrder.HandoffStatus != "pending_owner" { t.Fatalf("order status = %q/%q, want pending_handoff/pending_owner", latestOrder.Status, latestOrder.HandoffStatus) } var latestListing model.RentalListing if err := db.First(&latestListing, fixture.Listing.ID).Error; err != nil { t.Fatalf("find listing failed: %v", err) } var latestAccount model.GameAccount if err := db.First(&latestAccount, fixture.Account.ID).Error; err != nil { t.Fatalf("find account failed: %v", err) } if latestListing.Status != "rented" || latestAccount.Status != "rented" { t.Fatalf("asset status = %q/%q, want rented/rented", latestListing.Status, latestAccount.Status) } } func TestConfirmPaidRollsBackOrderWhenPaymentUpdateFails(t *testing.T) { db := setupPaymentTestDB(t) orderRepo := ordermodule.NewRepository(db) repo := NewRepository(db, nil, orderRepo) fixture := createPayableOrderFixture(t, db, "0011", false) fixture.Payment.ID = 999999 paidAt := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt, map[string]string{ "provider_order_id": "PROVIDER202606140011", }, channelSourceNotify) if err == nil { t.Fatal("confirmPaid() error = nil, want payment lookup failure") } var latestOrder model.RentalOrder if findErr := db.First(&latestOrder, fixture.Order.ID).Error; findErr != nil { t.Fatalf("find order failed: %v", findErr) } if latestOrder.Status != "pending_payment" || latestOrder.HandoffStatus != "none" { t.Fatalf("order status = %q/%q, want rollback to pending_payment/none", latestOrder.Status, latestOrder.HandoffStatus) } var latestListing model.RentalListing if findErr := db.First(&latestListing, fixture.Listing.ID).Error; findErr != nil { t.Fatalf("find listing failed: %v", findErr) } var latestAccount model.GameAccount if findErr := db.First(&latestAccount, fixture.Account.ID).Error; findErr != nil { t.Fatalf("find account failed: %v", findErr) } if latestListing.Status != "published" || latestAccount.Status != "published" { t.Fatalf("asset status = %q/%q, want rollback to published/published", latestListing.Status, latestAccount.Status) } } // TestQueryClosedPaymentReturnsLocalStatus 验证关闭状态不再依赖渠道配置或继续轮询渠道。 func TestQueryClosedPaymentReturnsLocalStatus(t *testing.T) { db := setupPaymentTestDB(t) repo := NewRepository(db, nil, nil) payment := model.PaymentOrder{ PaymentNo: "PAY20260614CLOSED", OrderID: 9001, OrderNo: "ORD20260614CLOSED", UserID: 8001, Provider: "lakala", ThirdOrderID: "PAY20260614CLOSED", AmountCent: 1000, BizType: "order_pay", Status: "closed", } if err := db.Create(&payment).Error; err != nil { t.Fatalf("create payment failed: %v", err) } dto, err := repo.Query(t.Context(), payment.UserID, payment.OrderID) if err != nil { t.Fatalf("Query() error = %v", err) } if dto.Status != "closed" { t.Fatalf("status = %q, want closed", dto.Status) } } // TestApplyChannelStatusKeepsTerminalPayment 验证已关闭支付单不会被渠道普通状态重新刷回支付中。 func TestApplyChannelStatusKeepsTerminalPayment(t *testing.T) { db := setupPaymentTestDB(t) repo := NewRepository(db, nil, nil) payment := model.PaymentOrder{ PaymentNo: "PAY20260614TERMINAL", OrderID: 9002, OrderNo: "ORD20260614TERMINAL", UserID: 8002, Provider: "lakala", ThirdOrderID: "PAY20260614TERMINAL", AmountCent: 1000, BizType: "order_pay", Status: "closed", } if err := db.Create(&payment).Error; err != nil { t.Fatalf("create payment failed: %v", err) } if err := repo.applyChannelStatus(t.Context(), &payment, "paying", "", map[string]string{"status": "paying"}, channelSourceNotify); err != nil { t.Fatalf("applyChannelStatus() error = %v", err) } var latest model.PaymentOrder if err := db.First(&latest, payment.ID).Error; err != nil { t.Fatalf("find payment failed: %v", err) } if latest.Status != "closed" { t.Fatalf("status = %q, want closed", latest.Status) } }