Files
affiliate_dash/backend/internal/service/delivery_test.go
T
2026-07-31 12:24:54 +08:00

246 lines
8.9 KiB
Go

package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"affiliate_dash/internal/model"
)
func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "delivery-link", 5000, 5, 100)
fulfillmentSvc := NewFulfillmentService(db, nil)
deliverySvc := NewDeliveryService(fulfillmentSvc, "https://bff.example", "dlc", "https://shop.example", "link-secret", 60)
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 1,
ClientOrderNo: "delivery-link-001",
SKU: product.SKU,
})
if err != nil {
t.Fatalf("create order: %v", err)
}
link, err := deliverySvc.GetOrCreateDeliveryLink(merchantID, created.Order.OrderNo)
if err != nil {
t.Fatalf("get delivery link: %v", err)
}
if !strings.Contains(link.DeliveryURL, "exp=") || !strings.Contains(link.DeliveryURL, "sign=") {
t.Fatalf("delivery url should contain exp/sign, got %s", link.DeliveryURL)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err != nil {
t.Fatalf("authorize delivery link: %v", err)
}
if err := deliverySvc.RevokeDeliveryLink(merchantID, created.Order.OrderNo); err != nil {
t.Fatalf("revoke delivery link: %v", err)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
t.Fatalf("revoked link should be rejected")
} else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 {
t.Fatalf("expected 403 after revoke, got %v", err)
}
var order model.FulfillmentOrder
if err := db.Where("order_no = ?", created.Order.OrderNo).First(&order).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if order.DeliveryLinkExpiresAt == nil {
t.Fatalf("delivery_link_expires_at should be stored")
}
if order.DeliveryLinkRevokedAt == nil {
t.Fatalf("delivery_link_revoked_at should be stored")
}
restored, err := deliverySvc.RestoreDeliveryLink(merchantID, created.Order.OrderNo)
if err != nil {
t.Fatalf("restore delivery link: %v", err)
}
if restored.Exp == link.Exp || restored.Sign == link.Sign || restored.DeliveryURL == link.DeliveryURL {
t.Fatalf("restored link should rotate exp/sign, old=%+v restored=%+v", link, restored)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
t.Fatalf("old link should stay invalid after restore")
} else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 {
t.Fatalf("expected 403 for old link after restore, got %v", err)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: restored.Exp, Sign: restored.Sign}); err != nil {
t.Fatalf("restored link should be accepted: %v", err)
}
}
func TestDeliveryMerchantApiBindAndSubmit(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "delivery-api", 5000, 5, 100)
product := model.Product{Code: "delivery-api-alt-product", Name: "测试发货商品", Status: model.ProductStatusActive}
if err := db.Create(&product).Error; err != nil {
t.Fatalf("create product: %v", err)
}
if err := db.Create(&model.MerchantProduct{
MerchantID: merchantID,
ProductID: product.ID,
SKU: "suit_alan_walker",
DisplayName: "测试发货商品",
PriceAmount: 100,
Currency: "POINT",
Stock: 5,
Status: model.ProductStatusActive,
}).Error; err != nil {
t.Fatalf("create merchant product: %v", err)
}
var bindCalls, boundCalls, queueCreateCalls, queuePatchCalls, upstreamCalls int32
var requestLog []string
mockBFF := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/sign-proxy" {
w.WriteHeader(http.StatusNotFound)
return
}
var payload struct {
Path string `json:"path"`
Method string `json:"method"`
Data json.RawMessage `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
requestLog = append(requestLog, payload.Method+" "+payload.Path)
w.Header().Set("Content-Type", "application/json")
switch payload.Path {
case "/public/goods/detail":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"good": map[string]interface{}{
"title": "测试发货商品",
"image": "https://img.example/good.png",
},
},
})
case "/public/games/bind-account":
atomic.AddInt32(&bindCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"bind_uuid": "bind-123",
"url": "https://bind.example/qrcode",
},
})
case "/public/games/account-bound":
atomic.AddInt32(&boundCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"gameAccount": map[string]interface{}{
"game_account": "4808146277",
"game_account_role_name": "巫师哈丁12",
"game_account_area": "安卓",
"game_account_plat": "QQ",
"game_account_channel": "安卓-QQ",
},
},
})
case "/public/users/orders-queue":
if payload.Method == http.MethodPost {
atomic.AddInt32(&queueCreateCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"orders": []map[string]interface{}{
{"_id": "queue-1"},
},
},
})
return
}
if payload.Method == http.MethodPatch {
atomic.AddInt32(&queuePatchCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
case "/public/users/orders":
atomic.AddInt32(&upstreamCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"order": map[string]interface{}{
"order_no": "provider-1",
},
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockBFF.Close()
fulfillmentSvc := NewFulfillmentService(db, nil)
deliverySvc := NewDeliveryService(fulfillmentSvc, mockBFF.URL, "dlc", "https://shop.example", "link-secret", 60)
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 1,
ClientOrderNo: "delivery-api-001",
SKU: "suit_alan_walker",
})
if err != nil {
t.Fatalf("create order: %v", err)
}
info, err := deliverySvc.GetMerchantOrder(merchantID, created.Order.OrderNo)
if err != nil {
t.Fatalf("get merchant order: %v", err)
}
if !info.CanShip || info.Product == nil || info.Product.Image == "" {
t.Fatalf("merchant order should include delivery data: %+v", info)
}
bind, err := deliverySvc.BindForMerchant(merchantID, created.Order.OrderNo, "4808146277")
if err != nil {
t.Fatalf("bind for merchant: %v", err)
}
if bind.BindUUID != "bind-123" || bind.BindURL == "" || bind.QRURL == "" {
t.Fatalf("unexpected bind result: %+v", bind)
}
submitted, err := deliverySvc.SubmitForMerchant(merchantID, 88, created.Order.OrderNo, "4808146277", bind.BindUUID)
if err != nil {
t.Fatalf("submit for merchant: %v", err)
}
if submitted.Status != model.FulfillmentStatusProcessing || submitted.ProviderOrderNo != "provider-1" {
t.Fatalf("unexpected submit result: %+v", submitted)
}
var afterSubmit model.FulfillmentOrder
if err := db.Where("order_no = ?", created.Order.OrderNo).First(&afterSubmit).Error; err != nil {
t.Fatalf("reload after submit: %v", err)
}
if afterSubmit.FulfillmentStatus != model.FulfillmentStatusProcessing || afterSubmit.ProviderOrderNo != "provider-1" {
t.Fatalf("submit should persist processing state: %+v", afterSubmit)
}
if atomic.LoadInt32(&queueCreateCalls) != 1 || atomic.LoadInt32(&upstreamCalls) != 1 {
t.Fatalf("first submit should only create one queue, queue=%d upstream=%d requests=%v", queueCreateCalls, upstreamCalls, requestLog)
}
second, err := deliverySvc.SubmitForMerchant(merchantID, 88, created.Order.OrderNo, "4808146277", bind.BindUUID)
if err != nil {
t.Fatalf("repeat submit for merchant: %v", err)
}
if second.ProviderOrderNo != "provider-1" || second.Status != model.FulfillmentStatusProcessing {
t.Fatalf("repeat submit should reuse existing result: %+v", second)
}
if atomic.LoadInt32(&queueCreateCalls) != 1 || atomic.LoadInt32(&upstreamCalls) != 1 {
t.Fatalf("submit should be idempotent, queue=%d upstream=%d requests=%v", queueCreateCalls, upstreamCalls, requestLog)
}
if atomic.LoadInt32(&bindCalls) != 1 || atomic.LoadInt32(&queuePatchCalls) != 1 || atomic.LoadInt32(&boundCalls) != 1 {
t.Fatalf("unexpected mock counts bind=%d bound=%d patch=%d", bindCalls, boundCalls, queuePatchCalls)
}
}