- ExoCrmClient.set_status: live SetStatus needs {Orders: [id], Status} and
replies per order, unlike the documented {ID, Status}
- receipts.crm_status_set_at (migration 0006); set right after creation,
retried by cron, row-locked to avoid a repeat PACKED overwriting a newer status
- CRM errors under capitalized 'Errors' and non-JSON replies are reported
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
4.8 KiB
Python
149 lines
4.8 KiB
Python
"""Тесты ExoCrmClient. Сеть замокана через respx — реальных запросов не делает."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
import respx
|
|
from httpx import Response
|
|
|
|
from app.core.config import Settings
|
|
from app.services.crm.client import CrmError
|
|
from app.services.crm.exo_client import ExoCrmClient
|
|
|
|
BASE_URL = "https://crm.example.test/api/1.1/"
|
|
|
|
|
|
def _settings() -> Settings:
|
|
return Settings(
|
|
secret_key="test-secret-key",
|
|
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
|
crm_base_url=BASE_URL,
|
|
crm_api_key="apikey123",
|
|
crm_secret_key="secret123",
|
|
crm_shop_key="shopkey123",
|
|
crm_sid=1,
|
|
) # type: ignore[arg-type]
|
|
|
|
|
|
ORDER_PAYLOAD = {
|
|
"ID": "1",
|
|
"CreateDateTime": "2026-09-20 10:00:00",
|
|
"RecipientDName": "Іван Іванов",
|
|
"RecipientPhone": "+380501112233",
|
|
"RecipientEmail": None,
|
|
"Waybill_Number": "",
|
|
"Notes": None,
|
|
"Total": {
|
|
"Cost": "0.00",
|
|
"Quantity": 1,
|
|
"Weight": 0,
|
|
"DiscountAmount": "0.00",
|
|
"DiscountPercent": "0.00",
|
|
"Amount": "100.00",
|
|
},
|
|
"Goods": [
|
|
{
|
|
"ID": "10",
|
|
"SKU": "SKU-1",
|
|
"Name": "Товар",
|
|
"Price": "100.00",
|
|
"Quantity": "1.000",
|
|
"DiscountAmount": "0.00",
|
|
"DiscountPercent": "0.00",
|
|
"Amount": "100.00",
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
class TestExoCrmClientGetOrders:
|
|
@respx.mock
|
|
async def test_sends_expected_request_body(self) -> None:
|
|
route = respx.post(BASE_URL).mock(
|
|
return_value=Response(200, json={"status": "OK", "result": []})
|
|
)
|
|
client = ExoCrmClient(_settings())
|
|
|
|
await client.get_orders(status="APPROVED")
|
|
|
|
sent = route.calls.last.request
|
|
body = sent.content
|
|
import json
|
|
|
|
parsed = json.loads(body)
|
|
assert parsed["apikey"] == "apikey123"
|
|
assert parsed["object"] == "Orders"
|
|
assert parsed["method"] == "GetOrders"
|
|
assert parsed["params"]["sid"] == 1
|
|
assert parsed["params"]["key"] == "shopkey123"
|
|
assert parsed["params"]["Status"] == "APPROVED"
|
|
assert "md5sum" in parsed
|
|
|
|
@respx.mock
|
|
async def test_parses_successful_response_into_order_out(self) -> None:
|
|
respx.post(BASE_URL).mock(
|
|
return_value=Response(200, json={"status": "OK", "result": [ORDER_PAYLOAD]})
|
|
)
|
|
client = ExoCrmClient(_settings())
|
|
|
|
orders = await client.get_orders(status="APPROVED")
|
|
|
|
assert len(orders) == 1
|
|
order = orders[0]
|
|
assert order.id == "1"
|
|
assert order.recipient_name == "Іван Іванов"
|
|
assert order.total.quantity == "1"
|
|
assert order.goods[0].sku == "SKU-1"
|
|
|
|
@respx.mock
|
|
async def test_raises_crm_error_on_error_status(self) -> None:
|
|
respx.post(BASE_URL).mock(
|
|
return_value=Response(
|
|
200, json={"status": "ERROR", "errors": {"1005": "Checksum Error"}}
|
|
)
|
|
)
|
|
client = ExoCrmClient(_settings())
|
|
|
|
with pytest.raises(CrmError, match="Checksum Error"):
|
|
await client.get_orders(status="APPROVED")
|
|
|
|
|
|
class TestExoCrmClientSetStatus:
|
|
@respx.mock
|
|
async def test_sends_order_list_and_parses_per_order_result(self) -> None:
|
|
import json
|
|
|
|
route = respx.post(BASE_URL).mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"<": "<",
|
|
"123901": {"Status": "Success", "ChangeStatus": "Success"},
|
|
">": ">",
|
|
},
|
|
)
|
|
)
|
|
|
|
await ExoCrmClient(_settings()).set_status(order_id="123901", status="PACKED")
|
|
|
|
body = json.loads(route.calls.last.request.content)
|
|
assert body["object"] == "Orders"
|
|
assert body["method"] == "SetStatus"
|
|
# Не {"ID": ...} из документации — боевая CRM принимает только список в Orders.
|
|
assert body["params"] == {"Orders": ["123901"], "Status": "PACKED"}
|
|
assert "md5sum" in body
|
|
|
|
@respx.mock
|
|
async def test_raises_on_capitalized_errors(self) -> None:
|
|
respx.post(BASE_URL).mock(
|
|
return_value=Response(200, json={"<": "<", "Errors": ["Undefined order list."]})
|
|
)
|
|
with pytest.raises(CrmError, match="Undefined order list"):
|
|
await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED")
|
|
|
|
@respx.mock
|
|
async def test_raises_on_non_json_reply(self) -> None:
|
|
respx.post(BASE_URL).mock(return_value=Response(200, text="<b>Notice</b>: ..."))
|
|
with pytest.raises(CrmError, match="не JSON"):
|
|
await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED")
|