feat(t221): add exact-order erp connector
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from shunyunbaoerp import api
|
||||
|
||||
from test_normalizer import raw_result
|
||||
|
||||
|
||||
VALID_KEY = "k" * 32
|
||||
|
||||
|
||||
class FakeClient:
|
||||
is_logged_in = True
|
||||
token_expires_at = None
|
||||
|
||||
def get_freight_details(self, order_number: str) -> dict:
|
||||
assert order_number == "SOURCE-12"
|
||||
return raw_result()
|
||||
|
||||
|
||||
def test_query_requires_configured_service_key(monkeypatch) -> None:
|
||||
monkeypatch.delenv("SHUNYUNBAO_SERVICE_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
api.require_api_key(VALID_KEY)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.detail == "CONNECTOR_API_KEY_NOT_CONFIGURED"
|
||||
|
||||
|
||||
def test_query_rejects_wrong_service_key(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
|
||||
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
api.require_api_key("x" * 32)
|
||||
|
||||
assert raised.value.status_code == 401
|
||||
assert raised.value.detail == "CONNECTOR_API_KEY_INVALID"
|
||||
|
||||
|
||||
def test_query_returns_only_normalized_fields(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
|
||||
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
|
||||
api.require_api_key(VALID_KEY)
|
||||
response = api.query_freight(
|
||||
api.FreightQueryRequest(order_number="SOURCE-12"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert len(response["orders"][0]["items"]) == 2
|
||||
encoded = json.dumps(response, ensure_ascii=False)
|
||||
assert "receiverTel" not in encoded
|
||||
assert "receiverAddr" not in encoded
|
||||
assert "PRIVATE-QUERY" not in encoded
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from shunyunbaoerp import (
|
||||
ERPAuthenticationError,
|
||||
ERPClient,
|
||||
ERPNotFoundError,
|
||||
ERPProtocolError,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
payload=None,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
content: bytes = b"",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.content = content
|
||||
self.headers = headers or {"Content-Type": "application/json"}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise requests.HTTPError(str(self.status_code))
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, responses: list[FakeResponse]) -> None:
|
||||
self.responses = list(responses)
|
||||
self.headers: dict[str, str] = {}
|
||||
self.calls: list[dict[str, object]] = []
|
||||
self.closed = False
|
||||
|
||||
def get(self, url: str, **kwargs):
|
||||
self.calls.append({"method": "GET", "url": url, **kwargs})
|
||||
return self.responses.pop(0)
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
self.calls.append({"method": method, "url": url, **kwargs})
|
||||
return self.responses.pop(0)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def envelope(data, *, status=True, msg="成功", code=None):
|
||||
return {"status": status, "msg": msg, "data": data, "code": code}
|
||||
|
||||
|
||||
def fake_jwt(exp: int = 2_000_000_000) -> str:
|
||||
encode = lambda value: base64.urlsafe_b64encode(
|
||||
json.dumps(value, separators=(",", ":")).encode()
|
||||
).decode().rstrip("=")
|
||||
return f"{encode({'alg': 'none'})}.{encode({'exp': exp, 'username': 'demo'})}.signature"
|
||||
|
||||
|
||||
def test_login_keeps_captcha_and_login_in_same_session() -> None:
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(
|
||||
content=b"image",
|
||||
headers={"Content-Type": "image/png", "Set-Cookie": "omitted"},
|
||||
),
|
||||
FakeResponse(
|
||||
envelope(
|
||||
{
|
||||
"user": {"id": 1, "username": "demo"},
|
||||
"token": fake_jwt(),
|
||||
}
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
client = ERPClient(session=session)
|
||||
|
||||
assert client.fetch_captcha() == b"image"
|
||||
user = client.login("demo", "secret", "abcd")
|
||||
|
||||
assert user["id"] == 1
|
||||
assert client.is_logged_in is True
|
||||
assert client.token_expires_at is not None
|
||||
assert urlparse(session.calls[0]["url"]).path == "/api/p/code1"
|
||||
assert urlparse(session.calls[1]["url"]).path == "/am/auth/login"
|
||||
assert session.calls[1]["json"] == {
|
||||
"username": "demo",
|
||||
"password": "secret",
|
||||
"code": "abcd",
|
||||
}
|
||||
|
||||
|
||||
def test_login_requires_captcha_from_same_client() -> None:
|
||||
client = ERPClient(session=FakeSession([]))
|
||||
|
||||
with pytest.raises(ERPProtocolError):
|
||||
client.login("demo", "secret", "abcd")
|
||||
|
||||
|
||||
def test_query_and_detail_reproduce_har_contract() -> None:
|
||||
stock = {
|
||||
"id": 99001122,
|
||||
"code": "FREIGHT-001",
|
||||
"orderCode": "PLATFORM-001",
|
||||
}
|
||||
detail = {
|
||||
"id": 99001122,
|
||||
"code": "FREIGHT-001",
|
||||
"details": [{"id": 2, "productQty": 1}],
|
||||
}
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(envelope(1)),
|
||||
FakeResponse(envelope({"total": 1, "list": [stock]})),
|
||||
FakeResponse(envelope({"total": 1, "list": [detail]})),
|
||||
]
|
||||
)
|
||||
client = ERPClient(session=session)
|
||||
|
||||
result = client.get_freight_details("FREIGHT-001")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["records"][0] == {"stock": stock, "detail": detail}
|
||||
assert [urlparse(call["url"]).path for call in session.calls] == [
|
||||
"/am/stock/listTotal",
|
||||
"/am/stock/list",
|
||||
"/am/stock/detail/listByStock",
|
||||
]
|
||||
|
||||
query = session.calls[0]["json"]["queries"][0]
|
||||
assert query == {
|
||||
"dvalue": "FREIGHT-001",
|
||||
"tableName": "t_stock",
|
||||
"colName": "allcode",
|
||||
"op": 6,
|
||||
"type": 0,
|
||||
"tableAlias": "t",
|
||||
"optType": 1,
|
||||
}
|
||||
assert len(session.calls[0]["json"]["columns"]) == 72
|
||||
assert session.calls[2]["params"] == {"hist": 0}
|
||||
assert session.calls[2]["json"] == {"ids": [99001122]}
|
||||
|
||||
|
||||
def test_not_found_stops_before_detail_request() -> None:
|
||||
session = FakeSession([FakeResponse(envelope(0))])
|
||||
client = ERPClient(session=session)
|
||||
|
||||
with pytest.raises(ERPNotFoundError):
|
||||
client.get_freight_details("missing")
|
||||
|
||||
assert len(session.calls) == 1
|
||||
|
||||
|
||||
def test_expired_session_becomes_authentication_error() -> None:
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(
|
||||
envelope(None, status=False, msg="未登录或登录过期", code="-2")
|
||||
)
|
||||
]
|
||||
)
|
||||
client = ERPClient(session=session)
|
||||
|
||||
with pytest.raises(ERPAuthenticationError):
|
||||
client.query_stock_by_order_number("FREIGHT-001")
|
||||
|
||||
|
||||
def test_rejects_unexpected_bulk_match() -> None:
|
||||
session = FakeSession([FakeResponse(envelope(101))])
|
||||
client = ERPClient(session=session, max_matches=100)
|
||||
|
||||
with pytest.raises(ERPProtocolError, match="安全上限"):
|
||||
client.query_stock_by_order_number("too-broad")
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from shunyunbaoerp import ERPProtocolError, normalize_freight_result
|
||||
|
||||
|
||||
def raw_result() -> dict:
|
||||
return {
|
||||
"count": 1,
|
||||
"query": {
|
||||
"orderNumber": "PRIVATE-QUERY",
|
||||
"matchField": "allcode",
|
||||
},
|
||||
"records": [
|
||||
{
|
||||
"stock": {
|
||||
"id": 12,
|
||||
"code": "SOURCE-12",
|
||||
"orderCode": "PLATFORM-12",
|
||||
"shopName": "来源店铺",
|
||||
"created": "2026-07-28 10:00:00",
|
||||
"orderStatus": 2,
|
||||
"purchaseStatus": 0,
|
||||
"isCancel": 0,
|
||||
"receiver": "不得返回",
|
||||
"receiverTel": "不得返回",
|
||||
"receiverAddr": "不得返回",
|
||||
},
|
||||
"detail": {
|
||||
"id": 12,
|
||||
"shopName": "来源店铺",
|
||||
"created": "2026-07-28 10:00:00",
|
||||
"details": [
|
||||
{
|
||||
"id": 102,
|
||||
"productTitle": "第二件商品",
|
||||
"productSpec": "蓝色,L",
|
||||
"sku": "",
|
||||
"variationSku": "BLUE-L",
|
||||
"productQty": 2,
|
||||
"productThumb": 190000002,
|
||||
"purchaseStatus": 0,
|
||||
"cost": "不得返回",
|
||||
},
|
||||
{
|
||||
"id": 101,
|
||||
"productTitle": "第一件商品",
|
||||
"productSpec": "灰色,2XL",
|
||||
"sku": "GRAY-2XL",
|
||||
"variationSku": "IGNORED",
|
||||
"productQty": 1,
|
||||
"productThumb": 190000001,
|
||||
"purchaseStatus": 0,
|
||||
},
|
||||
],
|
||||
"receiver": "不得返回",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_normalizes_all_items_and_excludes_private_fields() -> None:
|
||||
normalized = normalize_freight_result(raw_result())
|
||||
|
||||
assert normalized["schema_version"] == 1
|
||||
assert normalized["query"] == {"mode": "ORDER_NUMBER"}
|
||||
assert len(normalized["orders"]) == 1
|
||||
order = normalized["orders"][0]
|
||||
assert order["external_stock_id"] == "12"
|
||||
assert [item["external_item_id"] for item in order["items"]] == [
|
||||
"101",
|
||||
"102",
|
||||
]
|
||||
assert order["items"][1]["sku"] == "BLUE-L"
|
||||
assert order["items"][1]["quantity"] == 2
|
||||
assert order["items"][0]["product_thumb_ref"] == "190000001"
|
||||
|
||||
encoded = json.dumps(normalized, ensure_ascii=False)
|
||||
for forbidden in (
|
||||
"PRIVATE-QUERY",
|
||||
"receiver",
|
||||
"receiverTel",
|
||||
"receiverAddr",
|
||||
"cost",
|
||||
"不得返回",
|
||||
):
|
||||
assert forbidden not in encoded
|
||||
|
||||
|
||||
def test_invalid_procurement_fields_remain_reviewable() -> None:
|
||||
source = raw_result()
|
||||
item = source["records"][0]["detail"]["details"][0]
|
||||
item["productTitle"] = None
|
||||
item["detailProductName"] = None
|
||||
item["sku"] = ""
|
||||
item["variationSku"] = ""
|
||||
item["productQty"] = 0
|
||||
item["productThumb"] = None
|
||||
|
||||
normalized = normalize_freight_result(source)
|
||||
result = normalized["orders"][0]["items"][1]
|
||||
|
||||
assert result["title"] == ""
|
||||
assert result["sku"] == ""
|
||||
assert result["quantity"] is None
|
||||
assert result["product_thumb_ref"] is None
|
||||
|
||||
|
||||
def test_conflicting_duplicate_item_is_rejected() -> None:
|
||||
source = raw_result()
|
||||
duplicate = dict(source["records"][0]["detail"]["details"][0])
|
||||
duplicate["productTitle"] = "冲突内容"
|
||||
source["records"][0]["detail"]["details"].append(duplicate)
|
||||
|
||||
with pytest.raises(ERPProtocolError, match="重复身份"):
|
||||
normalize_freight_result(source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
lambda source: source["records"][0].update(detail=None),
|
||||
lambda source: source["records"][0]["detail"].update(id=99),
|
||||
lambda source: source["records"][0]["detail"].update(details=None),
|
||||
lambda source: source["records"][0]["detail"]["details"][0].update(
|
||||
id=None
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_identity_or_detail_shape_failure_rejects_whole_batch(
|
||||
mutation,
|
||||
) -> None:
|
||||
source = raw_result()
|
||||
mutation(source)
|
||||
|
||||
with pytest.raises(ERPProtocolError):
|
||||
normalize_freight_result(source)
|
||||
Reference in New Issue
Block a user