298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""电商平台签名与真实适配器测试。
|
||
|
||
覆盖:
|
||
1. 抖店 MD5 签名与 1688 HMAC-SHA256 签名的金标准向量
|
||
2. 抖店拉单真实调用(monkeypatch HTTP 缝隙)与分→元归一化
|
||
3. access_token 临期自动刷新
|
||
4. 未配置凭证时 Mock 回退
|
||
5. 1688 Ocean 协议签名参数构造
|
||
"""
|
||
|
||
import pytest
|
||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||
from decimal import Decimal
|
||
from model_bakery import baker
|
||
|
||
from apps.channel import adapters as ch_adapters
|
||
from apps.channel.signing import douyin_sign, alibaba1688_sign
|
||
from apps.catalog.models import Product
|
||
from apps.inventory.models import Warehouse
|
||
from apps.partner.models import Customer
|
||
from apps.sales.models import SalesOrder
|
||
from apps.channel.models import ChannelAccount
|
||
|
||
|
||
@pytest.fixture
|
||
def customer(tenant, db):
|
||
return baker.make(Customer, tenant=tenant, code="C_FIX", name="渠道客户")
|
||
|
||
|
||
@pytest.fixture
|
||
def warehouse(tenant, db):
|
||
return baker.make(Warehouse, tenant=tenant, code="WH_FIX", name="渠道仓")
|
||
|
||
|
||
# ============================================================
|
||
# 1. 签名金标准向量
|
||
# ============================================================
|
||
|
||
def test_douyin_sign_golden_vector():
|
||
params = {"app_key": "k1", "timestamp": "123", "v": "2"}
|
||
sign = douyin_sign(params, "sec1")
|
||
assert sign == "1F269B821E839A3A2A28978AC04AFFF6"
|
||
assert len(sign) == 32 and sign.isupper()
|
||
|
||
|
||
def test_alibaba1688_sign_golden_vector():
|
||
params = {"a": "1", "b": "2"}
|
||
sign = alibaba1688_sign(params, "s")
|
||
assert sign == "AFD1ED70462CA57C91A26BEE586FE975C88C02AD0F1A52735712C4E8E897CED2"
|
||
assert len(sign) == 64 and sign.isupper()
|
||
|
||
|
||
# ============================================================
|
||
# 2. 抖店真实拉单(HTTP 缝隙 monkeypatch)
|
||
# ============================================================
|
||
|
||
@pytest.fixture
|
||
def douyin_account(tenant, customer, warehouse):
|
||
return ChannelAccount.objects.create(
|
||
tenant=tenant,
|
||
platform="douyin",
|
||
shop_id="DY_REAL_01",
|
||
shop_name="真实抖店",
|
||
app_key="test_app_key",
|
||
app_secret="test_app_secret",
|
||
access_token="test_access_token",
|
||
refresh_token="test_refresh_token",
|
||
token_expires_at=datetime.now(dt_timezone.utc) + timedelta(days=7),
|
||
default_warehouse=warehouse,
|
||
default_customer=customer,
|
||
)
|
||
|
||
|
||
def test_douyin_fetch_orders_real_call(douyin_account, monkeypatch):
|
||
captured = {}
|
||
|
||
def fake_post(self, url, params):
|
||
captured["url"] = url
|
||
captured["params"] = params
|
||
return {
|
||
"code": 10000,
|
||
"data": {
|
||
"shop_order_list": [
|
||
{
|
||
"order_id_str": "DY9876543210",
|
||
"order_status": 100,
|
||
"pay_amount": 8800, # 88.00 元(单位分)
|
||
"post_receiver": {
|
||
"user_name": "赵六",
|
||
"mobile": "13800001111",
|
||
"province": "浙江省",
|
||
"city": "杭州市",
|
||
"town": "西湖区",
|
||
"detail": "文一西路100号",
|
||
},
|
||
"sku_order_list": [
|
||
{
|
||
"sku_id": "SKU-1",
|
||
"external_sku_id": "P_EXT_01",
|
||
"sku_num": 3,
|
||
"sku_order_item_price": 2900, # 29.00 元
|
||
}
|
||
],
|
||
}
|
||
]
|
||
},
|
||
}
|
||
|
||
monkeypatch.setattr(ch_adapters.DouyinChannelAdapter, "_http_post", fake_post)
|
||
orders = ch_adapters.DouyinChannelAdapter().fetch_orders(douyin_account)
|
||
|
||
# 1. 签名请求参数已正确构造
|
||
assert captured["url"] == f"{ch_adapters.DOUYIN_API_BASE}/order/searchList"
|
||
p = captured["params"]
|
||
assert p["method"] == "order.searchList"
|
||
assert p["app_key"] == "test_app_key"
|
||
assert p["access_token"] == "test_access_token"
|
||
assert len(p["sign"]) == 32 and p["sign"].isupper()
|
||
|
||
# 2. 归一化结果:分→元
|
||
assert len(orders) == 1
|
||
o = orders[0]
|
||
assert o["external_order_id"] == "DY9876543210"
|
||
assert Decimal(o["order_amount"]) == Decimal("88.00")
|
||
assert o["buyer_name"] == "赵六"
|
||
assert "西湖区" in o["receiver_address"]
|
||
assert o["items"][0]["product_code"] == "P_EXT_01"
|
||
assert o["items"][0]["quantity"] == "3"
|
||
assert Decimal(o["items"][0]["unit_price"]) == Decimal("29.00")
|
||
|
||
|
||
def test_douyin_token_auto_refresh(douyin_account, monkeypatch):
|
||
# Token 只剩 2 小时 → 触发刷新
|
||
douyin_account.token_expires_at = datetime.now(dt_timezone.utc) + timedelta(hours=2)
|
||
douyin_account.save(update_fields=["token_expires_at"])
|
||
|
||
call_log = []
|
||
|
||
def fake_post(self, url, params):
|
||
call_log.append(url)
|
||
if "refresh_token" in url:
|
||
return {
|
||
"code": 10000,
|
||
"data": {
|
||
"access_token": "NEW_TOKEN",
|
||
"refresh_token": "NEW_REFRESH",
|
||
"expires_in": 604800,
|
||
},
|
||
}
|
||
return {"code": 10000, "data": {"shop_order_list": []}}
|
||
|
||
monkeypatch.setattr(ch_adapters.DouyinChannelAdapter, "_http_post", fake_post)
|
||
ch_adapters.DouyinChannelAdapter().fetch_orders(douyin_account)
|
||
|
||
assert any("oauth2/refresh_token" in u for u in call_log)
|
||
douyin_account.refresh_from_db()
|
||
assert douyin_account.access_token == "NEW_TOKEN"
|
||
assert douyin_account.refresh_token == "NEW_REFRESH"
|
||
assert douyin_account.token_expires_at > datetime.now(dt_timezone.utc) + timedelta(days=6)
|
||
|
||
|
||
def test_douyin_push_stock(douyin_account, monkeypatch):
|
||
def fake_post(self, url, params):
|
||
assert "product/stockNum/update" in url
|
||
return {"code": 10000, "data": {"update_result": True}}
|
||
|
||
monkeypatch.setattr(ch_adapters.DouyinChannelAdapter, "_http_post", fake_post)
|
||
ok = ch_adapters.DouyinChannelAdapter().push_stock(douyin_account, "P001", Decimal("99"))
|
||
assert ok is True
|
||
|
||
|
||
def test_mock_fallback_without_credentials(tenant):
|
||
# 无 app_key 的抖音店铺 → 自动回退 Mock(能产出标准订单)
|
||
account = baker.make(
|
||
ChannelAccount, tenant=tenant, platform="douyin",
|
||
shop_id="DY_EMPTY", shop_name="无凭证店铺",
|
||
app_key="", app_secret="", access_token="",
|
||
)
|
||
baker.make(Product, tenant=tenant, code="P_MOCK", status="active", sale_price=Decimal("25.00"))
|
||
orders = ch_adapters.DouyinChannelAdapter().fetch_orders(account)
|
||
assert len(orders) == 1
|
||
assert orders[0]["order_status"] == "PAID"
|
||
|
||
|
||
# ============================================================
|
||
# 3. 1688 Ocean 协议
|
||
# ============================================================
|
||
|
||
@pytest.fixture
|
||
def ali_account(tenant, customer, warehouse):
|
||
return ChannelAccount.objects.create(
|
||
tenant=tenant,
|
||
platform="1688",
|
||
shop_id="ALI_01",
|
||
shop_name="1688 分销店",
|
||
app_key="ali_key",
|
||
app_secret="ali_secret",
|
||
access_token="ali_token",
|
||
token_expires_at=datetime.now(dt_timezone.utc) + timedelta(days=7),
|
||
default_warehouse=warehouse,
|
||
default_customer=customer,
|
||
)
|
||
|
||
|
||
def test_1688_build_signed_params(ali_account):
|
||
params = ch_adapters.alibaba1688_build_url_params(
|
||
ali_account,
|
||
"com.alibaba.trade/alibaba.trade.getBuyerOrderList",
|
||
{"status": "waitsellerreceive"},
|
||
)
|
||
assert params["_aop_key"] == "ali_key"
|
||
assert params["access_token"] == "ali_token"
|
||
assert params["_aop_timestamp"].isdigit()
|
||
assert len(params["_aop_signature"]) == 64 and params["_aop_signature"].isupper()
|
||
|
||
|
||
def test_1688_fetch_orders_real_call(ali_account, monkeypatch):
|
||
captured = {}
|
||
|
||
def fake_get(self, url, params):
|
||
captured["url"] = url
|
||
captured["params"] = params
|
||
return {
|
||
"result": {
|
||
"result": [
|
||
{
|
||
"baseInfo": {
|
||
"idOfStr": "ALI10086",
|
||
"status": "waitsellerreceive",
|
||
"totalAmount": 16800, # 168.00 元
|
||
"buyerLoginID": "buyer_alibaba",
|
||
"receiverProvince": "广东省",
|
||
"receiverCity": "深圳市",
|
||
"receiverAddress": "南山区科技园",
|
||
},
|
||
"productItems": [
|
||
{
|
||
"productID": "P_ALI_EXT",
|
||
"quantity": 4,
|
||
"itemAmount": 16800, # 行金额分 → 单价 42 元
|
||
}
|
||
],
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
monkeypatch.setattr(ch_adapters.Alibaba1688Adapter, "_http_get", fake_get)
|
||
orders = ch_adapters.Alibaba1688Adapter().fetch_orders(ali_account)
|
||
|
||
assert captured["url"].startswith(f"{ch_adapters.ALI1688_API_BASE}/openapi/param2/1/")
|
||
assert len(orders) == 1
|
||
o = orders[0]
|
||
assert o["external_order_id"] == "ALI10086"
|
||
assert Decimal(o["order_amount"]) == Decimal("168.00")
|
||
assert o["items"][0]["product_code"] == "P_ALI_EXT"
|
||
assert o["items"][0]["quantity"] == "4"
|
||
assert Decimal(o["items"][0]["unit_price"]) == Decimal("42.0000")
|
||
|
||
|
||
# ============================================================
|
||
# 4. 真实归一化结构 → 自动转销售单(端到端)
|
||
# ============================================================
|
||
|
||
@pytest.mark.django_db
|
||
def test_real_normalized_order_converts_to_sales_order(tenant, douyin_account, monkeypatch):
|
||
baker.make(Product, tenant=tenant, code="P_EXT_01", status="active", sale_price=Decimal("29.00"))
|
||
|
||
def fake_post(self, url, params):
|
||
return {
|
||
"code": 10000,
|
||
"data": {
|
||
"shop_order_list": [
|
||
{
|
||
"order_id_str": "DY-E2E-001",
|
||
"order_status": 100,
|
||
"pay_amount": 8700,
|
||
"post_receiver": {"user_name": "测试", "mobile": "138"},
|
||
"sku_order_list": [
|
||
{"sku_id": "S1", "external_sku_id": "P_EXT_01", "sku_num": 3, "sku_order_item_price": 2900}
|
||
],
|
||
}
|
||
]
|
||
},
|
||
}
|
||
|
||
monkeypatch.setattr(ch_adapters.DouyinChannelAdapter, "_http_post", fake_post)
|
||
|
||
from apps.channel import services as channel_services
|
||
result = channel_services.sync_channel_orders(douyin_account, auto_convert=True)
|
||
assert result["pulled_count"] == 1
|
||
assert result["converted_count"] == 1
|
||
assert result["failed_count"] == 0
|
||
|
||
so = SalesOrder.objects.get(tenant=tenant, remark__contains="DY-E2E-001")
|
||
assert so.total_amount == Decimal("87.00")
|
||
assert so.state == "confirmed"
|