- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format - Brand "Assistant System" in the top bar and page title - Backend error details returned to the UI (auth, orders, receipts, cash registers, Checkbox/CRM/NP errors) and CLI output - Tests updated for the new messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
9.1 KiB
Python
260 lines
9.1 KiB
Python
"""Тесты роутера заказов.
|
||
|
||
Auth подменена через dependency_overrides, сервисный слой (`app.services.orders`)
|
||
— через monkeypatch. `SessionDep` создаёт `AsyncSession`, но соединение с БД
|
||
открывается лениво только при первом запросе — раз сервисные функции его не
|
||
трогают, реальная БД не нужна.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import UTC, datetime
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.deps import get_crm_client, get_current_user
|
||
from app.db.models.order import Order
|
||
from app.db.models.user import User, UserRole
|
||
from app.main import app
|
||
from app.services.crm.stub_client import StubCrmClient
|
||
|
||
pytestmark = pytest.mark.usefixtures("_patch_orders_service")
|
||
|
||
|
||
def _user(role: UserRole) -> User:
|
||
return User(
|
||
id=uuid.uuid4(),
|
||
email="test@example.com",
|
||
password_hash="x",
|
||
full_name="Тест",
|
||
role=role,
|
||
is_active=True,
|
||
)
|
||
|
||
|
||
def _order(order_id: str = "1") -> Order:
|
||
return Order(
|
||
id=order_id,
|
||
create_date_time=datetime(2026, 9, 20, 10, 0, 0, tzinfo=UTC),
|
||
recipient_name="Тест Тестов",
|
||
recipient_phone="+380501112233",
|
||
recipient_email=None,
|
||
waybill_number="20450123456789",
|
||
notes=None,
|
||
total_amount_kopecks=120000,
|
||
goods=[
|
||
{
|
||
"id": "1",
|
||
"sku": "SKU-1",
|
||
"name": "Товар 1",
|
||
"price": "1200.00",
|
||
"quantity": "1.000",
|
||
"discount_amount": "0.00",
|
||
"discount_percent": "0.00",
|
||
"amount": "1200.00",
|
||
}
|
||
],
|
||
is_deleted=False,
|
||
deleted_at=None,
|
||
receipt_created_at=None,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
import app.api.v1.orders as orders_router
|
||
|
||
async def fake_sync(session: object, crm: object) -> None:
|
||
return None
|
||
|
||
async def fake_list(session: object, *, tab: str) -> list[Order]:
|
||
return [_order()] if tab == "no_receipt" else []
|
||
|
||
async def fake_delete(session: object, order_id: str) -> Order | None:
|
||
return _order(order_id) if order_id == "1" else None
|
||
|
||
monkeypatch.setattr(orders_router.orders_service, "sync_orders_from_crm", fake_sync)
|
||
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
|
||
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
|
||
|
||
async def fake_cod_in_transit(session: object) -> tuple[int, int]:
|
||
return 3, 245050
|
||
|
||
monkeypatch.setattr(orders_router.orders_service, "cod_in_transit", fake_cod_in_transit)
|
||
|
||
async def fake_update(
|
||
session: object, order_id: str, data: object
|
||
) -> tuple[Order, list[str]] | None:
|
||
if order_id == "locked":
|
||
raise orders_router.orders_service.OrderEditError("За замовленням уже створено чек")
|
||
if order_id != "1":
|
||
return None
|
||
order = _order(order_id)
|
||
order.edited_at = datetime(2026, 9, 24, tzinfo=UTC)
|
||
return order, ["notes"]
|
||
|
||
monkeypatch.setattr(orders_router.orders_service, "update_order", fake_update)
|
||
|
||
async def fake_latest(session: object, order_ids: list[str]) -> dict:
|
||
return {}
|
||
|
||
monkeypatch.setattr(
|
||
orders_router.receipts_service, "latest_receipts_by_order", fake_latest
|
||
)
|
||
|
||
async def fake_record(session: object, **kwargs: object) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr(orders_router.audit, "record", fake_record)
|
||
|
||
# session.commit() вызывается напрямую роутером после audit.record — сама
|
||
# сессия не трогает БД до первого execute/commit, поэтому патчим только
|
||
# commit, а не всю SessionDep.
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
async def fake_commit(self: AsyncSession) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr(AsyncSession, "commit", fake_commit)
|
||
|
||
|
||
@pytest.fixture
|
||
def client() -> TestClient:
|
||
app.dependency_overrides[get_crm_client] = lambda: StubCrmClient()
|
||
try:
|
||
yield TestClient(app)
|
||
finally:
|
||
app.dependency_overrides.pop(get_crm_client, None)
|
||
app.dependency_overrides.pop(get_current_user, None)
|
||
|
||
|
||
class TestListOrders:
|
||
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER])
|
||
def test_any_authenticated_role_can_list_no_receipt_orders(
|
||
self, client: TestClient, role: UserRole
|
||
) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
||
|
||
response = client.get("/api/v1/orders", params={"tab": "no_receipt"})
|
||
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert len(body) == 1
|
||
assert body[0]["id"] == "1"
|
||
assert body[0]["total_amount"] == "1200.00"
|
||
assert body[0]["has_receipt"] is False
|
||
assert body[0]["goods"][0]["sku"] == "SKU-1"
|
||
|
||
@pytest.mark.parametrize("tab", ["has_receipt", "refused"])
|
||
def test_other_tabs_are_passed_to_service(self, client: TestClient, tab: str) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||
|
||
response = client.get("/api/v1/orders", params={"tab": tab})
|
||
|
||
assert response.status_code == 200
|
||
assert response.json() == []
|
||
|
||
def test_unknown_tab_is_rejected(self, client: TestClient) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||
|
||
response = client.get("/api/v1/orders", params={"tab": "bogus"})
|
||
|
||
assert response.status_code == 422
|
||
|
||
def test_requires_authentication(self, client: TestClient) -> None:
|
||
response = client.get("/api/v1/orders")
|
||
assert response.status_code == 401
|
||
|
||
|
||
class TestDeleteOrder:
|
||
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER])
|
||
def test_admin_and_cashier_can_delete(self, client: TestClient, role: UserRole) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
||
|
||
response = client.delete("/api/v1/orders/1")
|
||
|
||
assert response.status_code == 204
|
||
|
||
def test_viewer_cannot_delete(self, client: TestClient) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER)
|
||
|
||
response = client.delete("/api/v1/orders/1")
|
||
|
||
assert response.status_code == 403
|
||
|
||
def test_deleting_unknown_order_is_404(self, client: TestClient) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||
|
||
response = client.delete("/api/v1/orders/does-not-exist")
|
||
|
||
assert response.status_code == 404
|
||
|
||
|
||
_UPDATE_BODY = {
|
||
"recipient_name": "Тест Тестов",
|
||
"recipient_phone": "+380501112233",
|
||
"waybill_number": "20450123456789",
|
||
"notes": "правка",
|
||
"total_amount": "1200.00",
|
||
"goods": [
|
||
{"id": "1", "sku": "SKU-1", "name": "Товар 1", "price": "1200.00", "quantity": "1"}
|
||
],
|
||
}
|
||
|
||
|
||
class TestUpdateOrder:
|
||
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER])
|
||
def test_admin_and_cashier_can_update(self, client: TestClient, role: UserRole) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
||
|
||
response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY)
|
||
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert body["edited"] is True
|
||
assert body["goods"][0]["sku"] == "SKU-1"
|
||
|
||
def test_viewer_cannot_update(self, client: TestClient) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER)
|
||
|
||
response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY)
|
||
|
||
assert response.status_code == 403
|
||
|
||
def test_unknown_order_is_404(self, client: TestClient) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||
|
||
response = client.patch("/api/v1/orders/nope", json=_UPDATE_BODY)
|
||
|
||
assert response.status_code == 404
|
||
|
||
def test_order_with_receipt_is_409(self, client: TestClient) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||
|
||
response = client.patch("/api/v1/orders/locked", json=_UPDATE_BODY)
|
||
|
||
assert response.status_code == 409
|
||
|
||
def test_empty_goods_is_rejected(self, client: TestClient) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||
|
||
response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []})
|
||
|
||
assert response.status_code == 422
|
||
|
||
|
||
class TestSummary:
|
||
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER])
|
||
def test_returns_cod_in_transit(self, client: TestClient, role: UserRole) -> None:
|
||
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
||
|
||
response = client.get("/api/v1/orders/summary")
|
||
|
||
assert response.status_code == 200
|
||
assert response.json() == {
|
||
"cod_in_transit_count": 3,
|
||
"cod_in_transit_amount": "2450.50",
|
||
}
|