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.CallbackSubscription{}, &model.CallbackDelivery{}, &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: "POINT", 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: "POINT", 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 TestFulfillmentCreateOrderAppliesMerchantFeeRate(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-rate", 1000, 5, 200) if err := db.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(map[string]interface{}{ "fee_type": model.FeeTypeRate, "fee_rate_bp": int64(250), }).Error; err != nil { t.Fatalf("update merchant fee: %v", err) } svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 21, ClientOrderNo: "client-fee-rate", SKU: product.SKU, Quantity: 2, }) if err != nil { t.Fatalf("create order: %v", err) } // baseAmount = 200 * 2 = 400; rate 250BP = 400 * 250 / 10000 = 10; total = 410 if created.Order.BaseAmount != 400 || created.Order.ServiceFeeAmount != 10 || created.Order.Amount != 410 { t.Fatalf("unexpected rate fee snapshot: %+v", created.Order) } if created.Order.FeeType != model.FeeTypeRate || created.Order.FeeRateBP != 250 { t.Fatalf("unexpected rate fee config snapshot: %+v", created.Order) } 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 != 590 { t.Fatalf("wallet should debit total amount, got %d", wallet.AvailableBalance) } } func TestFulfillmentCreateOrderAppliesMerchantFeeFixed(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-fixed", 1000, 5, 200) if err := db.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(map[string]interface{}{ "fee_type": model.FeeTypeFixed, "fee_fixed_amount": int64(30), }).Error; err != nil { t.Fatalf("update merchant fee: %v", err) } svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 22, ClientOrderNo: "client-fee-fixed", SKU: product.SKU, Quantity: 2, }) if err != nil { t.Fatalf("create order: %v", err) } // baseAmount = 400; fixed fee = 30; total = 430 if created.Order.BaseAmount != 400 || created.Order.ServiceFeeAmount != 30 || created.Order.Amount != 430 { t.Fatalf("unexpected fixed fee snapshot: %+v", created.Order) } if created.Order.FeeType != model.FeeTypeFixed || created.Order.FeeFixedAmount != 30 { t.Fatalf("unexpected fixed fee config snapshot: %+v", created.Order) } 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 != 570 { t.Fatalf("wallet should debit total amount, got %d", wallet.AvailableBalance) } } 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) } } func TestCreateTestOrderCreatesFulfillableOrderWithoutBilling(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-order", 0, 0, 100) originalStock := product.Stock svc := NewFulfillmentService(db, nil) order, err := svc.CreateTestOrder(CreateTestOrderInput{ MerchantID: merchantID, ActorUserID: 99, SKU: product.SKU, BuyerReference: "测试买家 A", Note: "联调测试", }) if err != nil { t.Fatalf("create test order: %v", err) } if !strings.HasPrefix(order.OrderNo, "O") || order.ClientOrderNo != "TEST-"+order.OrderNo { t.Fatalf("unexpected test order numbers: %+v", order) } if order.PaymentStatus != model.PaymentStatusPaid || order.FulfillmentStatus != model.FulfillmentStatusPending { t.Fatalf("unexpected test order status: %+v", order) } canShip, reason := CanFulfill(order) if !canShip || reason != "" { t.Fatalf("test order should be fulfillable, canShip=%v reason=%q", canShip, reason) } openOrder, err := svc.QueryOpenOrder(order.OrderNo) if err != nil { t.Fatalf("query open order: %v", err) } if !openOrder.CanShip || openOrder.Product == nil || openOrder.Product.SKU != product.SKU { t.Fatalf("unexpected open order: %+v", openOrder) } 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 != 0 { t.Fatalf("test order should not debit wallet, 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 != originalStock { t.Fatalf("test order should not decrease stock, got %d", refreshed.Stock) } var ledgerCount int64 db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount) if ledgerCount != 0 { t.Fatalf("test order should not create wallet ledger, got %d", ledgerCount) } } func TestCreateTestOrderSupportsFailedFulfillableStatus(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-failed", 0, -1, 100) svc := NewFulfillmentService(db, nil) order, err := svc.CreateTestOrder(CreateTestOrderInput{ MerchantID: merchantID, SKU: product.SKU, FulfillmentStatus: model.FulfillmentStatusFailed, Note: "等待重新发货", }) if err != nil { t.Fatalf("create failed test order: %v", err) } if order.FulfillmentStatus != model.FulfillmentStatusFailed || order.FailureReason != "等待重新发货" { t.Fatalf("unexpected failed test order: %+v", order) } openOrder, err := svc.QueryOpenOrder(order.OrderNo) if err != nil { t.Fatalf("query open failed order: %v", err) } if !openOrder.CanShip || openOrder.Status != "ship_failed" { t.Fatalf("failed test order should be re-fulfillable, got %+v", openOrder) } } func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-notify", 1000, 2, 100) codec, err := NewSecretCodec("test-master-key") if err != nil { t.Fatalf("codec: %v", err) } callbackSvc := NewCallbackService(db, codec) svc := NewFulfillmentService(db, callbackSvc) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 31, ClientOrderNo: "client-source-notify", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{ Name: "履约回调", URL: "https://example.com/callback", Events: "order.fulfillment.updated", }, 7); err != nil { t.Fatalf("create callback subscription: %v", err) } result, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "success", ProviderOrderNo: "SRC-10001", FailReason: "ignored", }) if err != nil { t.Fatalf("handle ship notify: %v", err) } if result.Status != "delivered" { t.Fatalf("unexpected notify result: %+v", result) } var order model.FulfillmentOrder if err := db.First(&order, created.Order.ID).Error; err != nil { t.Fatalf("query order: %v", err) } if order.FulfillmentStatus != model.FulfillmentStatusSucceeded || order.ProviderOrderNo != "SRC-10001" || order.DeliveredAt == nil { t.Fatalf("unexpected order after source notify: %+v", order) } var delivery model.CallbackDelivery if err := db.Where("merchant_id = ? AND event = ?", merchantID, "order.fulfillment.updated").First(&delivery).Error; err != nil { t.Fatalf("query callback delivery: %v", err) } if !strings.Contains(delivery.Payload, created.Order.OrderNo) || !strings.Contains(delivery.Payload, "SRC-10001") { t.Fatalf("callback payload should contain updated order data, got %s", delivery.Payload) } var auditCount int64 db.Model(&model.AuditLog{}).Where("entity_id = ? AND action = ?", created.Order.OrderNo, "ship.notify").Count(&auditCount) if auditCount != 1 { t.Fatalf("expected one ship notify audit, got %d", auditCount) } } func TestHandleShipNotifyRequiresFinalStatusAndFailureReason(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-contract", 1000, 2, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 31, ClientOrderNo: "client-source-contract", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } if _, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "processing", }); err == nil || !strings.Contains(err.Error(), "success/failed") { t.Fatalf("processing should be rejected, got %v", err) } if _, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "failed", }); err == nil || !strings.Contains(err.Error(), "fail_reason") { t.Fatalf("failed without reason should be rejected, got %v", err) } } func TestHandleShipNotifySuccessClearsPreviousResultFailureReason(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-clear-reason", 1000, 2, 100) svc := NewFulfillmentService(db, nil) created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ MerchantID: merchantID, APIClientID: 31, ClientOrderNo: "client-source-clear-reason", SKU: product.SKU, }) if err != nil { t.Fatalf("create order: %v", err) } if _, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "failed", FailReason: "渠道服校验失败", }); err != nil { t.Fatalf("mark failed: %v", err) } if _, err := svc.HandleShipNotify(ShipNotifyInput{ OrderNo: created.Order.OrderNo, ShipStatus: "success", }); err != nil { t.Fatalf("mark success: %v", err) } var order model.FulfillmentOrder if err := db.First(&order, created.Order.ID).Error; err != nil { t.Fatalf("query order: %v", err) } if order.FulfillmentStatus != model.FulfillmentStatusSucceeded || order.FailureReason != "" { t.Fatalf("success should clear order failure reason, got %+v", order) } if strings.Contains(order.ResultData, "fail_reason") { t.Fatalf("success should clear result_data fail_reason, got %s", order.ResultData) } } func TestBuildShipNotifyResultDataSuccessRemovesFailureReason(t *testing.T) { result := buildShipNotifyResultData(`{"ship_status":"failed","fail_reason":"旧失败原因"}`, ShipNotifyInput{ ShipStatus: "success", }, nil) if strings.Contains(result, "fail_reason") { t.Fatalf("success result data should remove fail_reason, got %s", result) } if !strings.Contains(result, `"ship_status":"success"`) { t.Fatalf("success result data should keep ship_status, got %s", result) } }