All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy Trade-In / deploy (push) Successful in 1m24s
PR-C платёжного контура: token.py (sign + verify_notification_token, оба эталонных вектора Т-Банка перепроверены независимо), receipt.py (54-ФЗ ФФД 1.05, целые копейки), tbank_client.py (Init/GetState/CheckOrder/Confirm/Cancel, таймаут 15с, 4xx не ретраится). Слой инертный: 0 импортёров, роутеров нет. Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
208 lines
8.1 KiB
Python
208 lines
8.1 KiB
Python
"""Тесты `app.services.payments.receipt` — сборка Receipt (ФФД 1.05, услуга)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from app.services.payments.receipt import (
|
||
ReceiptBuildError,
|
||
ReceiptItem,
|
||
build_receipt,
|
||
receipt_total_kopecks,
|
||
)
|
||
|
||
|
||
def _item(price_kopecks: int = 99000, quantity: int = 1, tax: str = "none") -> ReceiptItem:
|
||
return ReceiptItem(
|
||
name="Отчёт об оценке квартиры (электронный)",
|
||
price_kopecks=price_kopecks,
|
||
quantity=quantity,
|
||
tax=tax, # type: ignore[arg-type]
|
||
)
|
||
|
||
|
||
# ── happy path ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_build_receipt_happy_path_with_email() -> None:
|
||
receipt = build_receipt(items=[_item()], taxation="usn_income", email="buyer@example.com")
|
||
|
||
assert receipt["Taxation"] == "usn_income"
|
||
assert receipt["Email"] == "buyer@example.com"
|
||
assert "Phone" not in receipt
|
||
assert len(receipt["Items"]) == 1
|
||
|
||
item = receipt["Items"][0]
|
||
assert item["Name"] == "Отчёт об оценке квартиры (электронный)"
|
||
assert item["Price"] == 99000
|
||
assert item["Quantity"] == 1
|
||
assert item["Amount"] == 99000
|
||
assert item["Tax"] == "none"
|
||
assert item["PaymentMethod"] == "full_payment"
|
||
assert item["PaymentObject"] == "service"
|
||
|
||
|
||
def test_build_receipt_happy_path_with_phone_only() -> None:
|
||
receipt = build_receipt(items=[_item()], taxation="osn", phone="+79990000000")
|
||
assert receipt["Phone"] == "+79990000000"
|
||
assert "Email" not in receipt
|
||
|
||
|
||
def test_build_receipt_accepts_both_email_and_phone() -> None:
|
||
receipt = build_receipt(
|
||
items=[_item()], taxation="osn", email="buyer@example.com", phone="+79990000000"
|
||
)
|
||
assert receipt["Email"] == "buyer@example.com"
|
||
assert receipt["Phone"] == "+79990000000"
|
||
|
||
|
||
def test_amount_is_price_times_quantity() -> None:
|
||
item = _item(price_kopecks=10000, quantity=3)
|
||
assert item.amount_kopecks == 30000
|
||
payload = item.to_payload()
|
||
assert payload["Amount"] == 30000
|
||
|
||
|
||
# ── Email/Phone обязательность ──────────────────────────────────────────────
|
||
|
||
|
||
def test_build_receipt_requires_email_or_phone() -> None:
|
||
with pytest.raises(ReceiptBuildError, match=r"Email.*Phone|Phone.*Email"):
|
||
build_receipt(items=[_item()], taxation="osn")
|
||
|
||
|
||
def test_build_receipt_rejects_blank_email_and_phone() -> None:
|
||
with pytest.raises(ReceiptBuildError):
|
||
build_receipt(items=[_item()], taxation="osn", email=" ", phone="")
|
||
|
||
|
||
# ── Items[].Name длина ───────────────────────────────────────────────────────
|
||
|
||
|
||
def test_item_name_exactly_128_chars_is_ok() -> None:
|
||
item = ReceiptItem(name="A" * 128, price_kopecks=1000)
|
||
payload = item.to_payload()
|
||
assert payload["Name"] == "A" * 128
|
||
|
||
|
||
def test_item_name_over_128_chars_rejected() -> None:
|
||
item = ReceiptItem(name="A" * 129, price_kopecks=1000)
|
||
with pytest.raises(ReceiptBuildError, match="128"):
|
||
item.to_payload()
|
||
|
||
|
||
def test_item_name_empty_rejected() -> None:
|
||
item = ReceiptItem(name="", price_kopecks=1000)
|
||
with pytest.raises(ReceiptBuildError):
|
||
item.to_payload()
|
||
|
||
|
||
# ── Price / Quantity валидация ──────────────────────────────────────────────
|
||
|
||
|
||
def test_item_zero_price_rejected() -> None:
|
||
item = ReceiptItem(name="X", price_kopecks=0)
|
||
with pytest.raises(ReceiptBuildError):
|
||
item.to_payload()
|
||
|
||
|
||
def test_item_negative_price_rejected() -> None:
|
||
item = ReceiptItem(name="X", price_kopecks=-100)
|
||
with pytest.raises(ReceiptBuildError):
|
||
item.to_payload()
|
||
|
||
|
||
def test_item_zero_quantity_rejected() -> None:
|
||
item = ReceiptItem(name="X", price_kopecks=100, quantity=0)
|
||
with pytest.raises(ReceiptBuildError):
|
||
item.to_payload()
|
||
|
||
|
||
# ── Taxation / Tax enum ──────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize("taxation", ["osn", "usn_income", "usn_income_outcome", "esn", "patent"])
|
||
def test_all_documented_taxation_values_accepted(taxation: str) -> None:
|
||
build_receipt(items=[_item()], taxation=taxation, email="a@test.ru") # type: ignore[arg-type]
|
||
|
||
|
||
def test_unknown_taxation_rejected() -> None:
|
||
with pytest.raises(ReceiptBuildError):
|
||
build_receipt(
|
||
items=[_item()],
|
||
taxation="usn",
|
||
email="a@test.ru", # type: ignore[arg-type]
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"tax_rate",
|
||
["none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"],
|
||
)
|
||
def test_all_documented_2026_tax_rates_accepted(tax_rate: str) -> None:
|
||
"""Актуальный список 2026 года — все значения проходят валидацию."""
|
||
item = _item(tax=tax_rate)
|
||
payload = item.to_payload()
|
||
assert payload["Tax"] == tax_rate
|
||
|
||
|
||
@pytest.mark.parametrize("removed_rate", ["vat20", "vat120"])
|
||
def test_removed_vat20_vat120_rates_rejected(removed_rate: str) -> None:
|
||
"""vat20/vat120 сняты с актуального списка 2026 — не должны проходить."""
|
||
item = _item(tax=removed_rate)
|
||
with pytest.raises(ReceiptBuildError, match="vat20/vat120"):
|
||
item.to_payload()
|
||
|
||
|
||
# ── Items[] границы ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_build_receipt_rejects_empty_items() -> None:
|
||
with pytest.raises(ReceiptBuildError):
|
||
build_receipt(items=[], taxation="osn", email="a@test.ru")
|
||
|
||
|
||
def test_build_receipt_rejects_more_than_100_items() -> None:
|
||
items = [_item() for _ in range(101)]
|
||
with pytest.raises(ReceiptBuildError, match="100"):
|
||
build_receipt(items=items, taxation="osn", email="a@test.ru")
|
||
|
||
|
||
def test_build_receipt_accepts_exactly_100_items() -> None:
|
||
items = [_item() for _ in range(100)]
|
||
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
|
||
assert len(receipt["Items"]) == 100
|
||
|
||
|
||
# ── инвариант: сумма Items[].Amount == сумме заказа ─────────────────────────
|
||
|
||
|
||
def test_receipt_total_matches_order_amount_invariant() -> None:
|
||
"""Ключевой инвариант задачи: сумма Items[].Amount == общей сумме заказа."""
|
||
order_amount_kopecks = 148500
|
||
items = [
|
||
ReceiptItem(name="Отчёт об оценке", price_kopecks=99000, quantity=1, tax="none"),
|
||
ReceiptItem(name="Персональный оффер", price_kopecks=49500, quantity=1, tax="none"),
|
||
]
|
||
receipt = build_receipt(items=items, taxation="usn_income", email="a@test.ru")
|
||
|
||
assert receipt_total_kopecks(receipt) == order_amount_kopecks
|
||
|
||
|
||
def test_receipt_total_multi_quantity_item() -> None:
|
||
items = [ReceiptItem(name="Оценка", price_kopecks=5000, quantity=4, tax="vat22")]
|
||
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
|
||
assert receipt_total_kopecks(receipt) == 20000
|
||
|
||
|
||
def test_receipt_total_kopecks_empty_items_key_returns_zero() -> None:
|
||
assert receipt_total_kopecks({"Taxation": "osn"}) == 0
|
||
|
||
|
||
def test_receipt_total_mismatch_detected_by_caller() -> None:
|
||
"""Демонстрирует, как вызывающая сторона обязана сверять сумму с Init.Amount."""
|
||
items = [ReceiptItem(name="Оценка", price_kopecks=10000, quantity=1, tax="none")]
|
||
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
|
||
|
||
wrong_init_amount_kopecks = 99999
|
||
assert receipt_total_kopecks(receipt) != wrong_init_amount_kopecks
|