package service import ( "strings" "testing" "affiliate_dash/internal/model" "affiliate_dash/internal/testdb" "gorm.io/gorm" ) func newServiceTestDB(t *testing.T) *gorm.DB { t.Helper() return testdb.New(t, &model.Merchant{}, &model.Product{}, &model.MerchantProduct{}, &model.WalletAccount{}, &model.WalletLedgerEntry{}, &model.FulfillmentOrder{}, &model.FulfillmentJob{}, &model.AuditLog{}, ) } func seedFulfillmentMerchant(t *testing.T, db *gorm.DB, code string, balance, stock, price int64) (uint, model.MerchantProduct) { t.Helper() merchant := model.Merchant{Code: code, Name: code, Status: model.MerchantStatusActive} if err := db.Create(&merchant).Error; err != nil { t.Fatalf("create merchant: %v", err) } if err := db.Create(&model.WalletAccount{ MerchantID: merchant.ID, Currency: "CNY", AvailableBalance: balance, }).Error; err != nil { t.Fatalf("create wallet: %v", err) } product := model.Product{Code: code + "-product", Name: "测试商品", Status: model.ProductStatusActive} if err := db.Create(&product).Error; err != nil { t.Fatalf("create product: %v", err) } merchantProduct := model.MerchantProduct{ MerchantID: merchant.ID, ProductID: product.ID, SKU: "sku-basic", DisplayName: "测试商品", PriceAmount: price, Currency: "CNY", Stock: stock, Status: model.ProductStatusActive, } if err := db.Create(&merchantProduct).Error; err != nil { t.Fatalf("create merchant product: %v", err) } return merchant.ID, merchantProduct } func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-a", 1000, 5, 200) svc := NewFulfillmentService(db, nil) first, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 11, ClientOrderNo: "client-001", SKU: product.SKU, Quantity: 2, RequestData: map[string]string{ "account": "player-1", }, }) if err != nil { t.Fatalf("create order: %v", err) } if first.Idempotent { t.Fatalf("first create should not be idempotent") } if first.Order.Amount != 400 || first.Order.PaymentStatus != model.PaymentStatusPaid || first.Order.FulfillmentStatus != model.FulfillmentStatusPending { t.Fatalf("unexpected order: %+v", first.Order) } second, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 11, ClientOrderNo: "client-001", SKU: product.SKU, Quantity: 2, }) if err != nil { t.Fatalf("idempotent create: %v", err) } if !second.Idempotent || second.Order.OrderNo != first.Order.OrderNo { t.Fatalf("expected existing order, got %+v", second) } var wallet model.WalletAccount if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { t.Fatalf("query wallet: %v", err) } if wallet.AvailableBalance != 600 { t.Fatalf("wallet should debit once, got %d", wallet.AvailableBalance) } var refreshed model.MerchantProduct if err := db.First(&refreshed, product.ID).Error; err != nil { t.Fatalf("query product: %v", err) } if refreshed.Stock != 3 { t.Fatalf("stock should decrease once, got %d", refreshed.Stock) } var ledgerCount int64 db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount) if ledgerCount != 1 { t.Fatalf("ledger should have one debit entry, got %d", ledgerCount) } } func TestFulfillmentCancelRefundsOnceAndRestoresStock(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-b", 1000, 2, 300) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 12, ClientOrderNo: "client-cancel", SKU: product.SKU, Quantity: 1, }) if err != nil { t.Fatalf("create order: %v", err) } cancelled, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "用户取消") if err != nil { t.Fatalf("cancel order: %v", err) } if cancelled.PaymentStatus != model.PaymentStatusRefunded || cancelled.FulfillmentStatus != model.FulfillmentStatusCancelled { t.Fatalf("unexpected cancelled order: %+v", cancelled) } if _, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "重复取消"); err != nil { t.Fatalf("repeat cancel should be idempotent: %v", err) } var wallet model.WalletAccount _ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error if wallet.AvailableBalance != 1000 { t.Fatalf("wallet should refund once, got %d", wallet.AvailableBalance) } var refreshed model.MerchantProduct _ = db.First(&refreshed, product.ID).Error if refreshed.Stock != 2 { t.Fatalf("stock should restore once, got %d", refreshed.Stock) } var ledgerCount int64 db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount) if ledgerCount != 2 { t.Fatalf("ledger should have debit and refund, got %d", ledgerCount) } } func TestFulfillmentStatusTransitions(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-c", 1000, -1, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 13, ClientOrderNo: "client-status", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } processing, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.FulfillmentStatusProcessing, }) if err != nil { t.Fatalf("mark processing: %v", err) } if processing.FulfillmentStatus != model.FulfillmentStatusProcessing { t.Fatalf("expected processing, got %s", processing.FulfillmentStatus) } succeeded, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.FulfillmentStatusSucceeded, ProviderOrderNo: "provider-1", ResultData: map[string]string{"ok": "true"}, }) if err != nil { t.Fatalf("mark succeeded: %v", err) } if succeeded.FulfillmentStatus != model.FulfillmentStatusSucceeded || succeeded.DeliveredAt == nil { t.Fatalf("unexpected succeeded order: %+v", succeeded) } _, err = svc.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: merchantID, APIClientID: 13, OrderNo: created.Order.OrderNo, Status: model.FulfillmentStatusFailed, }) if err == nil { t.Fatalf("should reject rollback after success") } } func TestFulfillmentMerchantIsolation(t *testing.T) { db := newServiceTestDB(t) merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100) merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-e", 1000, 1, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantA, APIClientID: 14, ClientOrderNo: "client-isolation", SKU: productA.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } if _, err := svc.GetOrder(merchantB, created.Order.OrderNo); err == nil { t.Fatalf("other merchant should not read the order") } } func TestWalletAdjustIsIdempotentPerMerchant(t *testing.T) { db := newServiceTestDB(t) merchantA, _ := seedFulfillmentMerchant(t, db, "merchant-f", 0, -1, 100) merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-g", 0, -1, 100) svc := NewFulfillmentService(db, nil) for _, merchantID := range []uint{merchantA, merchantB} { wallet, err := svc.AdjustWallet(WalletAdjustInput{ MerchantID: merchantID, ActorUserID: 1, Amount: 500, IdempotencyKey: "same-key", Note: "充值", }) if err != nil { t.Fatalf("adjust wallet merchant %d: %v", merchantID, err) } if wallet.AvailableBalance != 500 { t.Fatalf("unexpected balance for merchant %d: %d", merchantID, wallet.AvailableBalance) } } wallet, err := svc.AdjustWallet(WalletAdjustInput{ MerchantID: merchantA, ActorUserID: 1, Amount: 500, IdempotencyKey: "same-key", Note: "重复充值", }) if err != nil { t.Fatalf("repeat adjust: %v", err) } if wallet.AvailableBalance != 500 { t.Fatalf("repeat adjust should not change balance, got %d", wallet.AvailableBalance) } } func TestCanFulfill(t *testing.T) { ok, reason := CanFulfill(&model.FulfillmentOrder{ PaymentStatus: model.PaymentStatusPaid, FulfillmentStatus: model.FulfillmentStatusFailed, }) if !ok || reason != "" { t.Fatalf("failed paid order should be fulfillable") } ok, _ = CanFulfill(&model.FulfillmentOrder{ PaymentStatus: model.PaymentStatusRefunded, FulfillmentStatus: model.FulfillmentStatusPending, }) if ok { t.Fatalf("refunded order should not be fulfillable") } } func TestCreateOrderRejectsInsufficientBalance(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-h", 50, 1, 100) svc := NewFulfillmentService(db, nil) _, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 15, ClientOrderNo: "client-low-balance", SKU: product.SKU, }) if err == nil || !strings.Contains(err.Error(), "余额不足") { t.Fatalf("expected insufficient balance error, got %v", err) } var wallet model.WalletAccount _ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error if wallet.AvailableBalance != 50 { t.Fatalf("balance should remain unchanged, got %d", wallet.AvailableBalance) } }