"""电商平台签名与真实适配器测试。 覆盖: 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"