Bind Nova Poshta API key to cash register (#3)
Each cash register stores its own encrypted NP API key. Status polling uses register keys and binds an order to the register whose key sees the TTN as its own (PhoneSender present); ETTN receipts are created from that register. Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register. Closes #3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.nova_poshta.client import NovaPoshtaError
|
||||
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
|
||||
|
||||
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
|
||||
}
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
secret_key="test-secret-key",
|
||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||
nova_poshta_api_key="np-apikey-123",
|
||||
) # type: ignore[arg-type]
|
||||
API_KEY = "np-apikey-123"
|
||||
|
||||
|
||||
class TestNpTrackingClientGetStatuses:
|
||||
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
|
||||
route = respx.post(_API_URL).mock(
|
||||
return_value=Response(200, json={"success": True, "data": [], "errors": []})
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
sent = route.calls.last.request
|
||||
body = json.loads(sent.content)
|
||||
assert body["apiKey"] == "np-apikey-123"
|
||||
assert body["apiKey"] == API_KEY
|
||||
assert body["modelName"] == "TrackingDocument"
|
||||
assert body["calledMethod"] == "getStatusDocuments"
|
||||
assert body["methodProperties"]["Documents"] == [
|
||||
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
|
||||
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
assert len(statuses) == 1
|
||||
status = statuses[0]
|
||||
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
|
||||
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
||||
assert status.cod_amount == "699"
|
||||
assert status.payment_status == "NeedPayment"
|
||||
assert not status.is_own # в STATUS_PAYLOAD нет PhoneSender
|
||||
|
||||
def test_is_own_by_sender_phone(self) -> None:
|
||||
own = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": "380961112233"})
|
||||
foreign = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": ""})
|
||||
assert own.is_own
|
||||
assert not foreign.is_own
|
||||
|
||||
@respx.mock
|
||||
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
||||
@@ -82,9 +84,9 @@ class TestNpTrackingClientGetStatuses:
|
||||
respx.post(_API_URL).mock(
|
||||
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
assert statuses[0].cod_amount == "827.48"
|
||||
|
||||
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
|
||||
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
|
||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
async def test_returns_empty_list_for_no_documents(self) -> None:
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
assert await client.get_statuses(waybill_numbers=[]) == []
|
||||
assert await client.get_statuses(api_key=API_KEY, waybill_numbers=[]) == []
|
||||
|
||||
async def test_rejects_too_many_documents(self) -> None:
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Слишком много"):
|
||||
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)])
|
||||
await client.get_statuses(
|
||||
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Опрос статусов НП ключами касс и привязка заказов к кассам (`sync_np_statuses`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core import crypto
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.order import Order
|
||||
from app.services.nova_poshta.stub_client import StubNovaPoshtaClient
|
||||
from app.services.orders import sync_np_statuses
|
||||
|
||||
|
||||
def _register(name: str, np_key: str | None, *, is_default: bool = False) -> CashRegister:
|
||||
return CashRegister(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
license_key_enc=crypto.encrypt("lic"),
|
||||
cashier_pin_enc=crypto.encrypt("1111"),
|
||||
np_api_key_enc=crypto.encrypt(np_key) if np_key else None,
|
||||
tax_codes=[],
|
||||
is_active=True,
|
||||
is_default=is_default,
|
||||
created_at=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _order(waybill: str, register_id: uuid.UUID | None = None) -> Order:
|
||||
return Order(
|
||||
id=waybill[-4:],
|
||||
create_date_time=datetime(2026, 9, 20, tzinfo=UTC),
|
||||
waybill_number=waybill,
|
||||
total_amount_kopecks=120000,
|
||||
goods=[],
|
||||
is_deleted=False,
|
||||
cash_register_id=register_id,
|
||||
)
|
||||
|
||||
|
||||
def _status(waybill: str, *, own: bool) -> dict[str, Any]:
|
||||
status = {"Number": waybill, "Status": "В дорозі", "StatusCode": "5"}
|
||||
if own:
|
||||
status |= {"PhoneSender": "380961112233", "AfterpaymentOnGoodsCost": 1200}
|
||||
return status
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""`scalars` отдаёт кассы (с ключом НП, активные) или заказы — по сущности запроса."""
|
||||
|
||||
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
|
||||
self.registers = registers
|
||||
self.orders = orders
|
||||
self.commits = 0
|
||||
|
||||
async def scalars(self, statement: Any) -> list[Any]:
|
||||
entity = statement.column_descriptions[0]["entity"]
|
||||
if entity is CashRegister:
|
||||
return [r for r in self.registers if r.np_api_key_enc and r.is_active]
|
||||
return self.orders
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
async def test_unbound_order_is_claimed_by_register_that_sees_it_as_own() -> None:
|
||||
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
|
||||
order = _order("20450000000001")
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={
|
||||
"key-a": {order.waybill_number: _status(order.waybill_number, own=False)},
|
||||
"key-b": {order.waybill_number: _status(order.waybill_number, own=True)},
|
||||
}
|
||||
)
|
||||
session = FakeSession([first, second], [order])
|
||||
|
||||
await sync_np_statuses(session, np) # type: ignore[arg-type]
|
||||
|
||||
assert order.cash_register_id == second.id
|
||||
assert order.np_cod_amount_kopecks == 120000
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
async def test_bound_order_is_polled_only_with_its_register_key() -> None:
|
||||
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
|
||||
order = _order("20450000000002", register_id=second.id)
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={"key-b": {order.waybill_number: _status(order.waybill_number, own=True)}}
|
||||
)
|
||||
|
||||
await sync_np_statuses(FakeSession([first, second], [order]), np) # type: ignore[arg-type]
|
||||
|
||||
assert [key for key, _ in np.calls] == ["key-b"]
|
||||
assert order.cash_register_id == second.id
|
||||
|
||||
|
||||
async def test_foreign_waybill_keeps_status_but_no_register_or_cod() -> None:
|
||||
register = _register("A", "key-a", is_default=True)
|
||||
order = _order("20450000000003", register_id=register.id)
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={"key-a": {order.waybill_number: _status(order.waybill_number, own=False)}}
|
||||
)
|
||||
|
||||
await sync_np_statuses(FakeSession([register], [order]), np) # type: ignore[arg-type]
|
||||
|
||||
assert order.cash_register_id is None
|
||||
assert order.np_status_code == "5"
|
||||
assert order.np_cod_amount_kopecks is None
|
||||
|
||||
|
||||
async def test_no_registers_with_np_key_does_nothing() -> None:
|
||||
order = _order("20450000000004")
|
||||
np = StubNovaPoshtaClient()
|
||||
session = FakeSession([_register("A", None)], [order])
|
||||
|
||||
await sync_np_statuses(session, np) # type: ignore[arg-type]
|
||||
|
||||
assert np.calls == []
|
||||
assert session.commits == 0
|
||||
@@ -16,6 +16,7 @@ from app.core import crypto
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.order import Order
|
||||
from app.db.models.receipt import Receipt, ReceiptStatus
|
||||
from app.db.models.user import User
|
||||
from app.schemas.checkbox import EttnStatus
|
||||
from app.services import receipts as svc
|
||||
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
|
||||
@@ -182,6 +183,52 @@ class TestBuildBody:
|
||||
assert "delivery" not in body["receipt_body"]
|
||||
|
||||
|
||||
class TestRequestReceipts:
|
||||
"""Чек создаётся от кассы, к которой опрос НП привязал заказ."""
|
||||
|
||||
class Session:
|
||||
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
|
||||
self.registers, self.orders, self.added = registers, orders, []
|
||||
|
||||
async def scalars(self, statement: Any) -> list[Any]:
|
||||
entity = statement.column_descriptions[0]["entity"]
|
||||
if entity is CashRegister:
|
||||
return self.registers
|
||||
if entity is Order:
|
||||
return self.orders
|
||||
return [] # активные чеки заказов
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
@staticmethod
|
||||
def _user() -> User:
|
||||
return User(id=uuid.uuid4(), email="c@x.ua")
|
||||
|
||||
async def test_uses_order_register(self) -> None:
|
||||
default, other = _register(), _register()
|
||||
order = _order(cash_register_id=other.id)
|
||||
session = self.Session([default, other], [order])
|
||||
|
||||
result = await svc.request_receipts(
|
||||
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert result.errors == {}
|
||||
assert result.created[0].cash_register_id == other.id
|
||||
|
||||
async def test_unbound_order_is_rejected(self) -> None:
|
||||
order = _order(cash_register_id=None)
|
||||
session = self.Session([_register()], [order])
|
||||
|
||||
result = await svc.request_receipts(
|
||||
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert result.created == []
|
||||
assert "Касса не определена" in result.errors[order.id]
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, *objects: Any) -> None:
|
||||
self.objects = {(type(o), o.id): o for o in objects}
|
||||
|
||||
Reference in New Issue
Block a user