"""Тесты роутера заказов. 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", }