Files
dealerhub/backend/tests/test_channel_adapters.py
T

298 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""电商平台签名与真实适配器测试。
覆盖:
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"