Cashier creates an ETTN receipt in Checkbox bound to the TTN with payment control; Checkbox fiscalizes it itself when the parcel is paid for. - cash_registers (Fernet-encrypted license key / PIN) and receipts tables - Checkbox HTTP client + stub (ETTN does not work on test registers) - two-phase create via ARQ job, timeout reconciliation, cron status polling - /receipts and /cash-registers API, audit records - dashboard: per-order and bulk create, prepayment, cancel; cash registers page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
4.6 KiB
Python
151 lines
4.6 KiB
Python
"""Тесты HttpCheckboxClient. Сеть замокана через respx — реальных запросов не делает."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from httpx import Response
|
|
|
|
from app.core.config import Settings
|
|
from app.services.checkbox.client import (
|
|
CheckboxCredentials,
|
|
CheckboxError,
|
|
CheckboxUnavailableError,
|
|
)
|
|
from app.services.checkbox.http_client import HttpCheckboxClient
|
|
|
|
BASE = "https://api.checkbox.test"
|
|
CREDS = CheckboxCredentials(cash_register_id=uuid.uuid4(), license_key="lic-123", pin_code="1111")
|
|
|
|
ETTN = {
|
|
"id": "e-1",
|
|
"status": "CREATED",
|
|
"ettnNumber": "20450123456789",
|
|
"totalSum": 120000,
|
|
"description": "",
|
|
"recipientPhone": "",
|
|
"employee": {"id": "x", "dateCreated": "2026-09-23"},
|
|
"cashRegister": {"id": "y", "dateCreated": "2026-09-23"},
|
|
"lastCheckDate": "2026-09-23",
|
|
"dateCreated": "2026-09-23",
|
|
}
|
|
|
|
|
|
def _client() -> HttpCheckboxClient:
|
|
settings = Settings(
|
|
secret_key="test-secret-key",
|
|
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
|
checkbox_base_url=BASE,
|
|
) # type: ignore[arg-type]
|
|
return HttpCheckboxClient(settings)
|
|
|
|
|
|
def _signin(token: str = "tok-1") -> respx.Route:
|
|
return respx.post(f"{BASE}/api/v1/cashier/signinPinCode").mock(
|
|
return_value=Response(200, json={"access_token": token})
|
|
)
|
|
|
|
|
|
@respx.mock
|
|
async def test_create_signs_in_and_sends_headers() -> None:
|
|
signin = _signin()
|
|
create = respx.post(f"{BASE}/api/v1/ettn").mock(return_value=Response(200, json=ETTN))
|
|
|
|
ettn = await _client().create_ettn(CREDS, {"provider": "novapost"})
|
|
|
|
assert ettn.id == "e-1" and ettn.ettn_number == "20450123456789"
|
|
assert json.loads(signin.calls.last.request.content) == {"pin_code": "1111"}
|
|
assert signin.calls.last.request.headers["X-License-Key"] == "lic-123"
|
|
sent = create.calls.last.request
|
|
assert sent.headers["Authorization"] == "Bearer tok-1"
|
|
assert sent.headers["X-License-Key"] == "lic-123"
|
|
assert sent.headers["X-Client-Name"] == "lux_fiscal"
|
|
|
|
|
|
@respx.mock
|
|
async def test_token_is_cached_between_calls() -> None:
|
|
signin = _signin()
|
|
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(return_value=Response(200, json=ETTN))
|
|
client = _client()
|
|
|
|
await client.get_ettn(CREDS, "e-1")
|
|
await client.get_ettn(CREDS, "e-1")
|
|
|
|
assert signin.call_count == 1
|
|
|
|
|
|
@respx.mock
|
|
async def test_401_triggers_single_resign_in() -> None:
|
|
signin = _signin("tok-2")
|
|
route = respx.get(f"{BASE}/api/v1/ettn/e-1").mock(
|
|
side_effect=[Response(401, json={"message": "expired"}), Response(200, json=ETTN)]
|
|
)
|
|
|
|
ettn = await _client().get_ettn(CREDS, "e-1")
|
|
|
|
assert ettn.status == "CREATED"
|
|
assert signin.call_count == 2
|
|
assert route.calls.last.request.headers["Authorization"] == "Bearer tok-2"
|
|
|
|
|
|
@respx.mock
|
|
async def test_422_raises_checkbox_error_with_message() -> None:
|
|
_signin()
|
|
respx.post(f"{BASE}/api/v1/ettn").mock(
|
|
return_value=Response(
|
|
422,
|
|
json={
|
|
"message": "Validation error",
|
|
"detail": [{"loc": ["body", "payments"], "msg": "bad", "type": "x"}],
|
|
},
|
|
)
|
|
)
|
|
|
|
with pytest.raises(CheckboxError, match="body.payments: bad") as exc_info:
|
|
await _client().create_ettn(CREDS, {})
|
|
assert not isinstance(exc_info.value, CheckboxUnavailableError)
|
|
|
|
|
|
@respx.mock
|
|
async def test_timeout_and_5xx_are_unavailable() -> None:
|
|
_signin()
|
|
respx.post(f"{BASE}/api/v1/ettn").mock(side_effect=httpx.ReadTimeout("slow"))
|
|
with pytest.raises(CheckboxUnavailableError):
|
|
await _client().create_ettn(CREDS, {})
|
|
|
|
respx.post(f"{BASE}/api/v1/ettn").mock(return_value=Response(503, text="down"))
|
|
with pytest.raises(CheckboxUnavailableError):
|
|
await _client().create_ettn(CREDS, {})
|
|
|
|
|
|
@respx.mock
|
|
async def test_find_skips_cancelled_and_other_waybills() -> None:
|
|
_signin()
|
|
respx.get(f"{BASE}/api/v1/ettn").mock(
|
|
return_value=Response(
|
|
200,
|
|
json=[
|
|
{**ETTN, "id": "old", "status": "CANCELLED"},
|
|
{**ETTN, "id": "other", "ettnNumber": "111"},
|
|
{**ETTN, "id": "match"},
|
|
],
|
|
)
|
|
)
|
|
|
|
found = await _client().find_ettn(CREDS, "20450123456789")
|
|
|
|
assert found is not None and found.id == "match"
|
|
|
|
|
|
@respx.mock
|
|
async def test_bad_pin_raises() -> None:
|
|
respx.post(f"{BASE}/api/v1/cashier/signinPinCode").mock(
|
|
return_value=Response(400, json={"message": "Невірний пін-код"})
|
|
)
|
|
with pytest.raises(CheckboxError, match="Невірний пін-код"):
|
|
await _client().sign_in(CREDS)
|