From 5bd9c586b7dc3c081f7b35ac5071d36a0fdf1db4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 23:54:22 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(payments):=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20=D0=B1=D0=B0=D0=BD=D0=BA=D0=B0=20=D0=BD=D0=B0=20Init?= =?UTF-8?q?=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=BD=D0=B0=2030?= =?UTF-8?q?=20=D0=BC=D0=B8=D0=BD=D1=83=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строка после провального Init оставалась в NEW и без payment_url: для _find_live_payment (фильтр payment_url IS NOT NULL) её нет, а для предиката UNIQUE миграции 279 — есть. Следующий checkout ловил конфликт и получал 409 'retry shortly' до истечения _ABANDONED_AFTER_MINUTES — из-за сбоя банка, а не своего действия. Переводим такую строку в терминальный DEADLINE_EXPIRED (тот же, которым checkout уже помечает брошенные попытки) в том же UPDATE, что пишет error_code/error_message: статус вне предиката 279, пара (estimate_id, product_code) освобождается сразу. Новый статус в CHECK 233 не заводим — миграция ради ярлыка не нужна, причина и так в error_*. Refs #3323 --- tradein-mvp/backend/app/api/v1/payments.py | 43 +++++++++-- .../backend/tests/test_payments_router.py | 77 +++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/payments.py b/tradein-mvp/backend/app/api/v1/payments.py index dc48b658..bcd8c2be 100644 --- a/tradein-mvp/backend/app/api/v1/payments.py +++ b/tradein-mvp/backend/app/api/v1/payments.py @@ -187,6 +187,16 @@ _ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"}) # списания требуют, чтобы человек намеренно оплатил обе формы. _ABANDONED_AFTER_MINUTES = 30 +# Статус для строки, чей Init отвалился: своего кода вроде INIT_FAILED в +# CHECK миграции 233 нет, а заводить его ради этого случая значило бы менять +# схему ради ярлыка — «почему» и так лежит в error_code/error_message. Берём +# терминальный DEADLINE_EXPIRED, которым checkout уже помечает попытки, из +# которых платёж не выйдет сам. Обязательное свойство ровно одно: статус ВНЕ +# предиката 279 (= вне _REUSABLE_STATUSES), иначе мёртвая строка продолжит +# держать пару (estimate_id, product_code). Это стережёт +# tests/test_payments_router.py::test_init_failed_status_is_terminal. +_INIT_FAILED_STATUS = "DEADLINE_EXPIRED" + _ORDER_ID_PREFIX = "mera-" # 32 байта энтропии (43 символа base64url) — перебор capability-ссылки # неосуществим, а сама ссылка остаётся кликабельной в мессенджере. @@ -389,19 +399,40 @@ def checkout( ) ) except TBankApiError as exc: - # Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт - # попытки (и возможного холда, если обрыв случился после приёма запроса - # банком) не остался бы нигде. Слепой повтор Init по тому же order_id - # запрещён (см. докстринг init_payment) — это работа реконсиляции. + # Запись остаётся в БД с текстом ошибки — иначе факт попытки (и + # возможного холда, если обрыв случился после приёма запроса банком) не + # остался бы нигде. Слепой повтор Init по тому же order_id запрещён + # (см. докстринг init_payment) — это работа реконсиляции. + # + # А вот статус NEW оставлять нельзя: ссылки у этой строки нет и уже не + # будет, поэтому `_find_live_payment` её не увидит (там + # `payment_url IS NOT NULL`), но предикат UNIQUE миграции 279 — увидит, + # и следующий checkout получит конфликт вместо новой попытки. Покупатель + # оказался бы заперт на 30 минут (_ABANDONED_AFTER_MINUTES) из-за сбоя + # на стороне банка. Переводим строку в тот же терминальный + # DEADLINE_EXPIRED, которым выше помечаются брошенные попытки: он вне + # предиката 279, поэтому пара (estimate_id, product_code) освобождается + # той же транзакцией, а «почему» лежит в error_code/error_message. + # Форма покупателю не выдавалась, так что списать по этой строке нечего; + # если банк всё же пришлёт по ней нотификацию — статус-машина notify + # доведёт её до конца (терминальный статус не блокирует CONFIRMED). db.execute( text( """ UPDATE payments - SET error_code = :code, error_message = :message, updated_at = NOW() + SET status = :status, + error_code = :code, + error_message = :message, + updated_at = NOW() WHERE order_id = :order_id """ ), - {"code": exc.error_code[:64], "message": str(exc)[:500], "order_id": order_id}, + { + "status": _INIT_FAILED_STATUS, + "code": exc.error_code[:64], + "message": str(exc)[:500], + "order_id": order_id, + }, ) db.commit() logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc) diff --git a/tradein-mvp/backend/tests/test_payments_router.py b/tradein-mvp/backend/tests/test_payments_router.py index 25031832..aa6f0b58 100644 --- a/tradein-mvp/backend/tests/test_payments_router.py +++ b/tradein-mvp/backend/tests/test_payments_router.py @@ -772,6 +772,83 @@ def test_abandoned_checkout_does_not_lock_the_buyer_out( assert len(_paid_report_rows(db)) == 2 +def test_bank_refusal_on_init_does_not_lock_the_buyer_out( + client: TestClient, db: _FakeDb, monkeypatch: pytest.MonkeyPatch +) -> None: + """Отказ банка на Init не запирает покупателя до истечения окна. + + Строка после провального Init остаётся навсегда без `payment_url`: + `_find_live_payment` её не видит (там `payment_url IS NOT NULL`), а предикат + UNIQUE миграции 279 — видит. Если оставить ей статус NEW, следующий checkout + получит конфликт и ложный 409 «retry shortly» на все 30 минут + `_ABANDONED_AFTER_MINUTES` — из-за чужого сбоя, а не своего действия. + + Фальсификация (проверено `git apply -R`): убрать `status = :status` из + UPDATE в ветке TBankApiError → второй checkout отвечает 409 вместо 200, + новой строки и второго Init нет. Тест краснеет по значению. + """ + from app.api.v1 import payments as payments_module + from app.api.v1.payments import _REUSABLE_STATUSES + from app.services.payments.tbank_client import TBankApiError + + calls: list[dict[str, Any]] = [] + + class _FlakyClient: + async def init_payment(self, **kwargs: Any) -> dict[str, Any]: + calls.append(kwargs) + if len(calls) == 1: + raise TBankApiError("Init", "9999", "терминал временно недоступен") + return { + "Success": True, + "Status": "NEW", + "PaymentId": "3000000042", + "PaymentURL": "https://securepayments.tinkoff.ru/after-retry", + } + + monkeypatch.setattr(payments_module, "_client", _FlakyClient) + + failed = client.post("/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}) + assert failed.status_code == 502, failed.text + + dead = _paid_report_rows(db)[0] + assert dead.status not in _REUSABLE_STATUSES, "мёртвая строка осталась в предикате 279" + assert dead.payment_url is None + + retry = client.post("/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}) + + assert retry.status_code == 200, retry.text + assert retry.json()["payment_url"] == "https://securepayments.tinkoff.ru/after-retry" + assert len(_paid_report_rows(db)) == 2, "повтор после отказа банка не создал новую попытку" + assert len(calls) == 2 + + +def test_init_failed_status_is_outside_the_live_predicate() -> None: + """`_INIT_FAILED_STATUS` обязан быть вне предиката 279 и внутри CHECK 233. + + Сверка идёт с ТЕКСТОМ миграций, а не с константами того же модуля: статус, + попавший в предикат, вернул бы ложный 409, а статус вне CHECK уронил бы + UPDATE прямо в ветке обработки отказа банка. + """ + from app.api.v1.payments import _INIT_FAILED_STATUS + + sql_dir = _REPO_ROOT / "backend" / "data" / "sql" + predicate = re.search( + r"AND status IN \((.*?)\)", + (sql_dir / "279_payments_live_checkout_uidx.sql").read_text(encoding="utf-8"), + re.S, + ) + assert predicate is not None + assert _INIT_FAILED_STATUS not in set(re.findall(r"'([^']+)'", predicate.group(1))) + + check = re.search( + r"payments_status_check\s+CHECK \(status IN \((.*?)\)\)", + (sql_dir / "233_payments.sql").read_text(encoding="utf-8"), + re.S, + ) + assert check is not None, "CHECK статусов не найден в миграции 233" + assert _INIT_FAILED_STATUS in set(re.findall(r"'([^']+)'", check.group(1))) + + def test_checkout_rejects_foreign_estimate( client: TestClient, db: _FakeDb, bank: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch ) -> None: -- 2.45.3 From ec8213c26998f638f067cb1640f6bcb367d4c788 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 00:11:35 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(payments):=20=D0=BE=D0=B1=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20Init=20=D0=B1=D0=B5=D0=B7=20PaymentURL=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=8E=D1=82=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=80=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три дыры в одном замке (строка без payment_url невидима для _find_live_payment, но видима предикату UNIQUE 279 → ложный 409 на 30 минут): - tbank_client ловил пару (TimeoutException, NetworkError): RemoteProtocolError, ProxyError и UnsupportedProtocol летели наружу голым httpx-типом мимо `except TBankApiError` в checkout. Ловим родителя — httpx.TransportError. - Ветка «Success:true без PaymentURL» отвечала 502, не трогая статус, — здесь банк заказ ПРИНЯЛ. Тот же терминальный статус, error_code=no_payment_url; UPDATE вынесен в общий _mark_init_failed. - _FakeDb в тестах применял статус по наличию ключа в params, а не по тексту SQL: мутант без `SET status = :status` оставался зелёным. Гейт по SQL — мутант краснит все три теста про замок. Комментарий про «возможный холд» на ветке отказа Init поправлен: Init холд не создаёт, авторизация идёт с оплаты формы, а форму покупателю не выдавали. --- tradein-mvp/backend/app/api/v1/payments.py | 85 ++++++----- .../app/services/payments/tbank_client.py | 9 +- .../backend/tests/test_payments_router.py | 133 +++++++++++++++++- 3 files changed, 184 insertions(+), 43 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/payments.py b/tradein-mvp/backend/app/api/v1/payments.py index bcd8c2be..762d21ae 100644 --- a/tradein-mvp/backend/app/api/v1/payments.py +++ b/tradein-mvp/backend/app/api/v1/payments.py @@ -203,6 +203,36 @@ _ORDER_ID_PREFIX = "mera-" _REPORT_TOKEN_BYTES = 32 +def _mark_init_failed(db: Session, order_id: str, *, code: str, message: str) -> None: + """Переводит строку провалившегося Init в терминальный статус + «почему». + + Общая для ВСЕХ исходов, после которых ссылки у строки не будет: отказ банка + и Success:true без PaymentURL. Статус NEW тут оставлять нельзя — см. + комментарий к `_INIT_FAILED_STATUS`: строка без `payment_url` невидима для + `_find_live_payment`, но видима предикату UNIQUE миграции 279, и следующий + checkout получил бы ложный 409 на все `_ABANDONED_AFTER_MINUTES`. + """ + db.execute( + text( + """ + UPDATE payments + SET status = :status, + error_code = :code, + error_message = :message, + updated_at = NOW() + WHERE order_id = :order_id + """ + ), + { + "status": _INIT_FAILED_STATUS, + "code": code[:64], + "message": message[:500], + "order_id": order_id, + }, + ) + db.commit() + + def _require_enabled() -> None: """Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен.""" if not settings.payments_enabled: @@ -399,42 +429,17 @@ def checkout( ) ) except TBankApiError as exc: - # Запись остаётся в БД с текстом ошибки — иначе факт попытки (и - # возможного холда, если обрыв случился после приёма запроса банком) не - # остался бы нигде. Слепой повтор Init по тому же order_id запрещён - # (см. докстринг init_payment) — это работа реконсиляции. + # Запись остаётся в БД с текстом ошибки — иначе факт попытки (и заказа, + # который банк мог принять до обрыва) не остался бы нигде. Холда здесь + # быть не может: Init только заводит заказ и отдаёт ссылку на форму, а + # авторизация суммы происходит, когда покупатель платит по форме — её + # ему не выдавали. Слепой повтор Init по тому же order_id всё равно + # запрещён (см. докстринг init_payment) — это работа реконсиляции. # - # А вот статус NEW оставлять нельзя: ссылки у этой строки нет и уже не - # будет, поэтому `_find_live_payment` её не увидит (там - # `payment_url IS NOT NULL`), но предикат UNIQUE миграции 279 — увидит, - # и следующий checkout получит конфликт вместо новой попытки. Покупатель - # оказался бы заперт на 30 минут (_ABANDONED_AFTER_MINUTES) из-за сбоя - # на стороне банка. Переводим строку в тот же терминальный - # DEADLINE_EXPIRED, которым выше помечаются брошенные попытки: он вне - # предиката 279, поэтому пара (estimate_id, product_code) освобождается - # той же транзакцией, а «почему» лежит в error_code/error_message. - # Форма покупателю не выдавалась, так что списать по этой строке нечего; - # если банк всё же пришлёт по ней нотификацию — статус-машина notify - # доведёт её до конца (терминальный статус не блокирует CONFIRMED). - db.execute( - text( - """ - UPDATE payments - SET status = :status, - error_code = :code, - error_message = :message, - updated_at = NOW() - WHERE order_id = :order_id - """ - ), - { - "status": _INIT_FAILED_STATUS, - "code": exc.error_code[:64], - "message": str(exc)[:500], - "order_id": order_id, - }, - ) - db.commit() + # Статус — терминальный (см. `_mark_init_failed`); если банк всё же + # пришлёт по этой строке нотификацию, статус-машина notify доведёт её до + # конца (терминальный статус не блокирует CONFIRMED). + _mark_init_failed(db, order_id, code=exc.error_code, message=str(exc)) logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc) raise HTTPException(status_code=502, detail="payment provider error") from exc @@ -442,7 +447,15 @@ def checkout( tbank_payment_id = init.get("PaymentId") if not isinstance(payment_url, str) or not payment_url: # Success:true без PaymentURL — контракт банка нарушен; выдумывать - # ссылку нечем. + # ссылку нечем. Замок тот же, что и у отказа выше, и даже вернее: банк + # заказ ПРИНЯЛ, а ссылки у строки уже не будет — оставить её в NEW + # значит отдать следующему checkout ложный 409 на 30 минут. + _mark_init_failed( + db, + order_id, + code="no_payment_url", + message=f"Init: Success без PaymentURL (Status={init.get('Status')!r})", + ) logger.error("checkout: Init без PaymentURL, order_id=%s", order_id) raise HTTPException(status_code=502, detail="payment provider returned no payment url") diff --git a/tradein-mvp/backend/app/services/payments/tbank_client.py b/tradein-mvp/backend/app/services/payments/tbank_client.py index e9baa367..a90f9223 100644 --- a/tradein-mvp/backend/app/services/payments/tbank_client.py +++ b/tradein-mvp/backend/app/services/payments/tbank_client.py @@ -126,7 +126,14 @@ class TBankClient: try: async with httpx.AsyncClient(timeout=self._timeout) as client: response = await client.post(url, json=body) - except (httpx.TimeoutException, httpx.NetworkError) as exc: + # Родитель всех транспортных отказов, а не пара TimeoutException + + # NetworkError: RemoteProtocolError (банк оборвал ответ), ProxyError и + # UnsupportedProtocol мимо той пары летели наружу голым httpx-исключением. + # Вызывающая сторона ловит только TBankApiError, поэтому строка платежа + # оставалась NEW без payment_url — то есть невидимой для + # _find_live_payment, но видимой предикату UNIQUE миграции 279, и + # покупатель запирался на _ABANDONED_AFTER_MINUTES из-за сбоя банка. + except httpx.TransportError as exc: if attempt > max_retries: logger.error( "tbank client: %s — network error после %d попыток: %s", diff --git a/tradein-mvp/backend/tests/test_payments_router.py b/tradein-mvp/backend/tests/test_payments_router.py index aa6f0b58..6636f99d 100644 --- a/tradein-mvp/backend/tests/test_payments_router.py +++ b/tradein-mvp/backend/tests/test_payments_router.py @@ -52,6 +52,7 @@ sys.modules.setdefault("weasyprint", _wp_mock) sys.modules.setdefault("weasyprint.CSS", _wp_mock) sys.modules.setdefault("weasyprint.HTML", _wp_mock) +import httpx # noqa: E402 import pytest # noqa: E402 from fastapi import FastAPI # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 @@ -163,11 +164,21 @@ class _FakeDb: if "UPDATE payments" in sql and "DEADLINE_EXPIRED" in sql: return _Result(self._expire_abandoned(params)) if "UPDATE payments" in sql: + # Колонки берутся из ТЕКСТА запроса, а не из наличия ключа в params: + # по params фейк применял бы статус и к запросу, из которого + # `SET status = :status` выкинули, — мутант оставался бы зелёным. + sets_status = "status = :status" in sql + sets_url = "payment_url = :payment_url" in sql + sets_error = "error_code = :code" in sql for payment in self.payments: - if payment.order_id == params["order_id"] and "status" in params: + if payment.order_id != params["order_id"]: + continue + if sets_status: payment.status = params["status"] - if "payment_url" in params: - payment.payment_url = params["payment_url"] + if sets_url: + payment.payment_url = params["payment_url"] + if sets_error: + payment.error_code = params["code"] return _Result(None) if "FROM trade_in_estimates" in sql and sql.startswith("SELECT"): if params.get("id") != _ESTIMATE_UUID: @@ -783,9 +794,11 @@ def test_bank_refusal_on_init_does_not_lock_the_buyer_out( получит конфликт и ложный 409 «retry shortly» на все 30 минут `_ABANDONED_AFTER_MINUTES` — из-за чужого сбоя, а не своего действия. - Фальсификация (проверено `git apply -R`): убрать `status = :status` из - UPDATE в ветке TBankApiError → второй checkout отвечает 409 вместо 200, - новой строки и второго Init нет. Тест краснеет по значению. + Фальсификация (проверено мутацией): убрать `SET status = :status` из UPDATE + в `_mark_init_failed` (params оставить как есть) → второй checkout отвечает + 409 вместо 200, новой строки и второго Init нет. Тест краснеет по значению. + Мутант ловится потому, что `_FakeDb` применяет статус по тексту SQL, а не по + наличию ключа `status` в params. """ from app.api.v1 import payments as payments_module from app.api.v1.payments import _REUSABLE_STATUSES @@ -822,6 +835,114 @@ def test_bank_refusal_on_init_does_not_lock_the_buyer_out( assert len(calls) == 2 +def test_broken_connection_to_bank_does_not_lock_the_buyer_out( + client: TestClient, db: _FakeDb, monkeypatch: pytest.MonkeyPatch +) -> None: + """Обрыв соединения (`RemoteProtocolError`) — тот же замок, что и отказ банка. + + `_request` ловил пару (TimeoutException, NetworkError), мимо которой летят + RemoteProtocolError (банк оборвал ответ), ProxyError и UnsupportedProtocol. + Такое исключение выходило наружу голым httpx-типом, `except TBankApiError` в + checkout его не видел, статус строки оставался NEW без `payment_url` — и + следующий checkout получал ложный 409 на все 30 минут. + + Фальсификация (проверено мутацией): вернуть в `tbank_client._request` + `except (httpx.TimeoutException, httpx.NetworkError)` → первый checkout + отвечает 500 вместо 502, строка остаётся NEW, повтор отвечает 409. Красное + по значению. + """ + from app.api.v1 import payments as payments_module + from app.api.v1.payments import _REUSABLE_STATUSES + + async def _no_sleep(_delay: float) -> None: + return None + + async def _broken_post(*_args: Any, **_kwargs: Any) -> Any: + raise httpx.RemoteProtocolError("Server disconnected without sending a response") + + # Ретраи `_request` спят 2+4+8 с — сон гасим, иначе тест стоит 14 секунд. + monkeypatch.setattr("app.services.payments.tbank_client.asyncio.sleep", _no_sleep) + monkeypatch.setattr(httpx.AsyncClient, "post", _broken_post) + + failed = client.post("/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}) + assert failed.status_code == 502, failed.text + + dead = _paid_report_rows(db)[0] + assert dead.status not in _REUSABLE_STATUSES, "оборванный Init оставил строку в предикате 279" + assert dead.status == "DEADLINE_EXPIRED" + assert dead.error_code == "network_error" + assert dead.payment_url is None + + # Повтор идёт мимо httpx: проверяется, что пара (estimate_id, product_code) + # освободилась, а не то, как ведёт себя транспорт во второй раз. + class _WorkingClient: + async def init_payment(self, **_kwargs: Any) -> dict[str, Any]: + return { + "Success": True, + "Status": "NEW", + "PaymentId": "3000000043", + "PaymentURL": "https://securepayments.tinkoff.ru/after-disconnect", + } + + monkeypatch.setattr(payments_module, "_client", _WorkingClient) + + retry = client.post("/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}) + + assert retry.status_code == 200, retry.text + assert retry.json()["payment_url"] == "https://securepayments.tinkoff.ru/after-disconnect" + assert len(_paid_report_rows(db)) == 2, "повтор после обрыва не создал новую попытку" + + +def test_init_without_payment_url_does_not_lock_the_buyer_out( + client: TestClient, db: _FakeDb, monkeypatch: pytest.MonkeyPatch +) -> None: + """`Success:true` без `PaymentURL` — замок тот же, и здесь банк заказ ПРИНЯЛ. + + Ветка отвечала 502, не тронув статус: строка навсегда оставалась NEW без + ссылки, то есть невидимой для `_find_live_payment` и видимой предикату + UNIQUE миграции 279. Помечаем её тем же терминальным статусом с говорящим + `error_code`. + + Фальсификация (проверено мутацией): убрать `_mark_init_failed` из ветки + «нет PaymentURL» → повтор отвечает 409 вместо 200, второго Init нет. + """ + from app.api.v1 import payments as payments_module + from app.api.v1.payments import _REUSABLE_STATUSES + + calls: list[dict[str, Any]] = [] + + class _UrllessClient: + async def init_payment(self, **kwargs: Any) -> dict[str, Any]: + calls.append(kwargs) + if len(calls) == 1: + # Контракт банка нарушен: Success есть, ссылки нет. + return {"Success": True, "Status": "NEW", "PaymentId": "3000000044"} + return { + "Success": True, + "Status": "NEW", + "PaymentId": "3000000045", + "PaymentURL": "https://securepayments.tinkoff.ru/after-urlless", + } + + monkeypatch.setattr(payments_module, "_client", _UrllessClient) + + failed = client.post("/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}) + assert failed.status_code == 502, failed.text + + dead = _paid_report_rows(db)[0] + assert dead.status not in _REUSABLE_STATUSES, "строка без ссылки осталась в предикате 279" + assert dead.status == "DEADLINE_EXPIRED" + assert dead.error_code == "no_payment_url" + assert dead.payment_url is None + + retry = client.post("/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}) + + assert retry.status_code == 200, retry.text + assert retry.json()["payment_url"] == "https://securepayments.tinkoff.ru/after-urlless" + assert len(_paid_report_rows(db)) == 2, "повтор после пустого PaymentURL не создал попытку" + assert len(calls) == 2 + + def test_init_failed_status_is_outside_the_live_predicate() -> None: """`_INIT_FAILED_STATUS` обязан быть вне предиката 279 и внутри CHECK 233. -- 2.45.3