"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой.""" from __future__ import annotations import asyncio from datetime import UTC, datetime from typing import Any import pytest from sqlalchemy.dialects import postgresql from app.db.models.order import Order from app.schemas.orders import OrderUpdateIn from app.services.orders import ( OrderEditError, OrderTab, _parse_crm_datetime, _to_kopecks, cod_in_transit, list_orders, update_order, ) class TestToKopecks: def test_converts_decimal_string_to_kopecks(self) -> None: assert _to_kopecks("599.00") == 59900 def test_handles_fractional_kopecks(self) -> None: assert _to_kopecks("12.34") == 1234 def test_handles_integer_without_fraction(self) -> None: assert _to_kopecks("100") == 10000 def test_handles_zero(self) -> None: assert _to_kopecks("0.00") == 0 class TestParseCrmDatetime: def test_parses_crm_format(self) -> None: assert _parse_crm_datetime("2026-09-20 10:00:00") == datetime(2026, 9, 20, 10, 0, 0) class _FakeSession: def __init__(self, order: Order | None) -> None: self.order = order async def scalar(self, query: Any) -> Order | None: return self.order def _order(**overrides: Any) -> Order: fields: dict[str, Any] = { "id": "1", "recipient_name": "Тест", "recipient_phone": "0501112233", "recipient_email": None, "waybill_number": "20450000000001", "notes": None, "total_amount_kopecks": 120000, "goods": [], "is_deleted": False, "receipt_created_at": None, "edited_at": None, "np_status": "Прибув", "np_status_code": "7", "np_cod_amount_kopecks": 120000, "np_payment_status": "NeedPayment", } fields.update(overrides) return Order(**fields) _GOOD = { "id": "1", "sku": "A", "name": "Товар", "price": "600.00", "quantity": "2", "discount_amount": "50.00", } def _payload(**overrides: Any) -> OrderUpdateIn: data: dict[str, Any] = { "recipient_name": "Тест", "recipient_phone": "0501112233", "recipient_email": "", "waybill_number": "20450000000001", "notes": "", "total_amount": "1100.00", "goods": [_GOOD], } data.update(overrides) return OrderUpdateIn.model_validate(data) def _update(order: Order | None, payload: OrderUpdateIn) -> tuple[Order, list[str]]: result = asyncio.run(update_order(_FakeSession(order), "1", payload)) # type: ignore[arg-type] assert result is not None return result class TestUpdateOrder: def test_recomputes_line_amount_and_marks_edited(self) -> None: order, changed = _update(_order(), _payload()) assert order.goods[0]["amount"] == "1150.00" assert order.goods[0]["quantity"] == "2.000" assert order.total_amount_kopecks == 110000 assert order.edited_at is not None assert set(changed) == {"goods", "total_amount"} def test_blank_strings_become_null(self) -> None: order, _ = _update(_order(), _payload(notes=" ")) assert order.notes is None assert order.recipient_email is None def test_new_line_gets_generated_id(self) -> None: good = {"sku": "", "name": "Новый", "price": "10.00", "quantity": "1"} order, _ = _update(_order(), _payload(goods=[good], total_amount="10.00")) assert order.goods[0]["id"].startswith("new-") def test_waybill_change_resets_np_tracking(self) -> None: order, changed = _update(_order(), _payload(waybill_number="20450000000002")) assert "waybill_number" in changed assert order.np_status is None assert order.np_cod_amount_kopecks is None def test_no_changes_does_not_mark_edited(self) -> None: # Снимок в формате CRM ("2", а не "2.000") — формат строк не считается правкой. stored = {**_GOOD, "discount_percent": "4.17", "amount": "1150.00"} order, changed = _update(_order(goods=[stored], total_amount_kopecks=110000), _payload()) assert changed == [] assert order.edited_at is None assert order.goods == [stored] def test_total_above_goods_sum_is_rejected(self) -> None: with pytest.raises(OrderEditError, match="більша за суму товарів"): _update(_order(), _payload(total_amount="1200.00")) def test_discount_above_line_sum_is_rejected(self) -> None: good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"} with pytest.raises(OrderEditError, match="знижка більша"): _update(_order(), _payload(goods=[good], total_amount="0")) def test_order_with_receipt_is_locked(self) -> None: with pytest.raises(OrderEditError, match="уже створено чек"): _update(_order(receipt_created_at=datetime.now(UTC)), _payload()) def test_missing_or_deleted_order_returns_none(self) -> None: payload = _payload() assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type] deleted = _FakeSession(_order(is_deleted=True)) assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type] class _CapturingSession: def __init__(self) -> None: self.statement: Any = None async def scalars(self, statement: Any) -> list[Order]: self.statement = statement return [] def _list_where(tab: OrderTab) -> str: """WHERE-часть запроса вкладки (SELECT перечисляет все колонки, в т.ч. receipt_created_at).""" session = _CapturingSession() asyncio.run(list_orders(session, tab=tab)) # type: ignore[arg-type] sql = str( session.statement.compile( dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} ) ) return sql.split("WHERE", 1)[1] class TestListOrders: def test_refused_tab_filters_by_refusal_codes_only(self) -> None: sql = _list_where(OrderTab.REFUSED) assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql assert "receipt_created_at" not in sql def test_received_tab_filters_by_received_codes_only(self) -> None: sql = _list_where(OrderTab.RECEIVED) assert "orders.np_status_code IN ('9', '10', '11', '106')" in sql assert "receipt_created_at" not in sql @pytest.mark.parametrize("tab", [OrderTab.NO_RECEIPT, OrderTab.HAS_RECEIPT]) def test_other_tabs_exclude_received_and_refusals(self, tab: OrderTab) -> None: sql = _list_where(tab) assert "NOT IN ('9', '10', '11', '106', '102', '103', '105', '108')" in sql assert "receipt_created_at" in sql class _CapturingExecuteSession: def __init__(self) -> None: self.statement: Any = None async def execute(self, statement: Any) -> Any: self.statement = statement class _Result: def one(self) -> tuple[int, int]: return 2, 150000 return _Result() class TestCodInTransit: def test_excludes_received_refused_and_paid(self) -> None: session = _CapturingExecuteSession() result = asyncio.run(cod_in_transit(session)) # type: ignore[arg-type] sql = str( session.statement.compile( dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} ) ) assert result == (2, 150000) assert "sum(orders.np_cod_amount_kopecks)" in sql assert "orders.is_deleted IS false" in sql assert "orders.np_cod_amount_kopecks IS NOT NULL" in sql assert ( "orders.np_status_code NOT IN " "('9', '10', '11', '106', '102', '103', '105', '108', '2', '3')" in sql ) assert "orders.np_payment_status IS DISTINCT FROM 'Payed'" in sql