Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m3s
#3034 свёл impersonate к единственной константе внутри scraper_kit и поднял профиль до chrome146. Сторож литерала сканирует только пакет, а его докстрока объявила остальное «отдельным периметром вне scope», сославшись на #2361 F4a. Периметр не спящий — он ходит в сеть каждый день, а #2361 к тому моменту был закрыт, то есть отсылка вела в никуда. На chrome120 оставались: app/services/cian_session.py:164 верификация куки Циана app/services/yandex_address_backfill.py:153 бэкфилл адресов app/tasks/yandex_detail_backfill.py:303 detail-бэкфилл Разрыв в 31 мажорную версию живёт в TLS-отпечатке (JA3/JA4), а не в строке User-Agent, поэтому сменой прокси он не лечится. ПРО ЦИАН ОТДЕЛЬНО. По #2673 оценка Циана мертва с 29 июня — «куки протухли, ни одной новой строки 37 дней». Путь, которым проверяется живость этих куки, всё это время представлялся площадке браузером двухлетней давности. Причину этим не объявляю: утверждаю, что при таком отпечатке отличить «куки протухли» от «нас узнали по рукопожатию» нечем. ТЕСТ ЗАКРЕПЛЯЛ ДЕФЕКТ. test_cian_session прибивал chrome120 гвоздём: подъём профиля в kit ронял бы этот тест, а «починкой» выглядел бы возврат к устаревшему профилю. Теперь тест сверяется с DEFAULT_IMPERSONATE. Сторож литерала расширен на backend/app — без этого периметр возвращается молча, что уже один раз и произошло. Намеренно НЕ входят tests/fixtures/** (номер профиля там — часть записи о том, чем снят фикстур-HTML) и scripts/** (разовые инструменты, в прод-путях не участвуют). Исторические замеры в комментариях сохранены как замеры: «curl_cffi с kit-профилем (на момент замера — Chrome 120)» вместо переписывания истории. Проверено: сканер сторожа на дереве даёт ноль нарушителей, на подсаженном литерале краснеет; все изменённые модули компилируются. Refs #3148
494 lines
20 KiB
Python
494 lines
20 KiB
Python
"""Tests for cian_session — cookie management service."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
||
|
||
from app.services.cian_session import (
|
||
CIAN_REQUIRED_COOKIES,
|
||
VERIFY_BAN_SENTINEL,
|
||
VERIFY_MARKUP_CHANGED_SENTINEL,
|
||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
|
||
_classify_verify_response,
|
||
load_session,
|
||
mark_session_invalid,
|
||
save_session,
|
||
verify_session,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture()
|
||
def mock_db() -> MagicMock:
|
||
db = MagicMock()
|
||
# Default: no row found (load_session → None)
|
||
db.execute.return_value.mappings.return_value.first.return_value = None
|
||
return db
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _mock_resolve_proxy_url_sync(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""#2825: verify_session больше не читает settings.cian_proxy_url напрямую, а
|
||
зовёт resolve_proxy_url_sync("cian") (пул scrape_proxies + fallback внутри
|
||
app.services.proxy_egress, отдельно покрыт tests/services/test_proxy_egress.py).
|
||
Без мока это реальный SessionLocal() -> живая (падающая в test-окружении) БД,
|
||
из-за чего verify_session уходил в generic except ДО session.get и все
|
||
verify_session-тесты ниже ловили не то, что проверяют. Мокаем на уровне модуля,
|
||
чтобы не трогать каждый тест по отдельности."""
|
||
monkeypatch.setattr(
|
||
"app.services.cian_session.resolve_proxy_url_sync",
|
||
lambda source: "http://test-proxy.local:8080",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CIAN_REQUIRED_COOKIES
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_required_cookies_set_not_empty() -> None:
|
||
assert isinstance(CIAN_REQUIRED_COOKIES, set)
|
||
assert len(CIAN_REQUIRED_COOKIES) >= 5
|
||
|
||
|
||
def test_required_cookies_contains_key_names() -> None:
|
||
assert "_cian_visitor_session_id" in CIAN_REQUIRED_COOKIES
|
||
assert "_ym_uid" in CIAN_REQUIRED_COOKIES
|
||
assert "_cian_uid" in CIAN_REQUIRED_COOKIES
|
||
assert "tlsr_id" in CIAN_REQUIRED_COOKIES
|
||
assert "cf_clearance" in CIAN_REQUIRED_COOKIES
|
||
|
||
|
||
def test_required_cookies_includes_real_auth() -> None:
|
||
"""Real Cian DevTools probe (2026-05-23) — these cookies must pass filter."""
|
||
real_cookies_from_browser = {
|
||
"DMIR_AUTH",
|
||
"_CIAN_GK",
|
||
"_yasc",
|
||
"_ym_uid",
|
||
"_ym_d",
|
||
"_ym_isad",
|
||
"_ym_visorc",
|
||
"uxfb_card_satisfaction",
|
||
}
|
||
missing = real_cookies_from_browser - CIAN_REQUIRED_COOKIES
|
||
assert not missing, f"Filter missing real cookies: {missing}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# save_session
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_save_session_calls_pgp_sym_encrypt(mock_db: MagicMock) -> None:
|
||
save_session(mock_db, account_user_id=12345, cookies={"_ym_uid": "abc"})
|
||
args, _ = mock_db.execute.call_args
|
||
sql_text = str(args[0])
|
||
assert "pgp_sym_encrypt" in sql_text
|
||
assert "cian_session_cookies" in sql_text
|
||
|
||
|
||
def test_save_session_uses_cast_not_colon_colon(mock_db: MagicMock) -> None:
|
||
"""Verify psycopg v3 compatibility — no ::bigint syntax in SQL."""
|
||
save_session(mock_db, account_user_id=99, cookies={"csrftoken": "x"})
|
||
args, _ = mock_db.execute.call_args
|
||
sql_text = str(args[0])
|
||
# CAST(:x AS type) is required; ::type would fail with psycopg v3 named params
|
||
assert "CAST(" in sql_text
|
||
assert "::bigint" not in sql_text
|
||
|
||
|
||
def test_save_session_commits(mock_db: MagicMock) -> None:
|
||
save_session(mock_db, account_user_id=12345, cookies={"_ym_uid": "abc"})
|
||
assert mock_db.commit.called
|
||
|
||
|
||
def test_save_session_on_conflict_do_update(mock_db: MagicMock) -> None:
|
||
save_session(mock_db, account_user_id=1, cookies={"_ym_uid": "v"})
|
||
args, _ = mock_db.execute.call_args
|
||
sql_text = str(args[0])
|
||
assert "ON CONFLICT" in sql_text
|
||
assert "DO UPDATE" in sql_text
|
||
|
||
|
||
def test_save_session_passes_correct_params(mock_db: MagicMock) -> None:
|
||
save_session(mock_db, account_user_id=777, cookies={"_ga": "GA1.2.x"}, ttl_days=14)
|
||
_, kwargs = mock_db.execute.call_args
|
||
params = kwargs if kwargs else mock_db.execute.call_args[0][1]
|
||
# params могут передаваться как второй позиционный аргумент
|
||
call_args = mock_db.execute.call_args
|
||
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("params", {})
|
||
assert params["uid"] == 777
|
||
assert params["ttl_days"] == 14
|
||
cookies_payload = json.loads(params["cookies_json"])
|
||
assert cookies_payload == {"_ga": "GA1.2.x"}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# load_session
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_load_session_returns_none_when_empty(mock_db: MagicMock) -> None:
|
||
result = load_session(mock_db)
|
||
assert result is None
|
||
|
||
|
||
def test_load_session_decodes_json(mock_db: MagicMock) -> None:
|
||
mock_row = {
|
||
"account_user_id": 12345,
|
||
"cookies_json": json.dumps({"_ym_uid": "abc", "_cian_uid": "def"}),
|
||
"expires_at_estimate": None,
|
||
}
|
||
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
|
||
result = load_session(mock_db)
|
||
assert result == {"_ym_uid": "abc", "_cian_uid": "def"}
|
||
|
||
|
||
def test_load_session_updates_last_used_at(mock_db: MagicMock) -> None:
|
||
mock_row = {
|
||
"account_user_id": 42,
|
||
"cookies_json": json.dumps({"session-id": "s"}),
|
||
"expires_at_estimate": None,
|
||
}
|
||
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
|
||
load_session(mock_db)
|
||
# Должно быть минимум 2 вызова execute: SELECT + UPDATE last_used_at
|
||
assert mock_db.execute.call_count >= 2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# mark_session_invalid
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_mark_session_invalid_updates_table(mock_db: MagicMock) -> None:
|
||
mark_session_invalid(mock_db, account_user_id=12345)
|
||
args, _ = mock_db.execute.call_args
|
||
sql = str(args[0])
|
||
assert "last_invalid_at" in sql
|
||
assert "cian_session_cookies" in sql
|
||
|
||
|
||
def test_mark_session_invalid_commits(mock_db: MagicMock) -> None:
|
||
mark_session_invalid(mock_db, account_user_id=99)
|
||
assert mock_db.commit.called
|
||
|
||
|
||
def test_mark_session_invalid_uses_cast(mock_db: MagicMock) -> None:
|
||
mark_session_invalid(mock_db, account_user_id=5)
|
||
args, _ = mock_db.execute.call_args
|
||
sql = str(args[0])
|
||
assert "CAST(" in sql
|
||
assert "::bigint" not in sql
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _classify_verify_response (pure unit tests — no I/O)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_classify_403_returns_ban_sentinel() -> None:
|
||
"""HTTP 403 TLS/bot ban → VERIFY_BAN_SENTINEL (not None, not 'expired')."""
|
||
result = _classify_verify_response(403, None)
|
||
assert result is VERIFY_BAN_SENTINEL
|
||
assert result is not None # must NOT collapse into expired-cookies signal
|
||
|
||
|
||
def test_classify_401_returns_none() -> None:
|
||
"""HTTP 401 Unauthorized → None (genuinely expired cookies)."""
|
||
result = _classify_verify_response(401, None)
|
||
assert result is None
|
||
|
||
|
||
def test_classify_200_not_authenticated_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""200 with isAuthenticated=false → None (expired)."""
|
||
monkeypatch.setattr(
|
||
"app.services.cian_session.extract_state",
|
||
lambda html, mfe, key: {"user": {"isAuthenticated": False, "userId": None}},
|
||
)
|
||
result = _classify_verify_response(200, "<html></html>")
|
||
assert result is None
|
||
|
||
|
||
def test_classify_200_authenticated_returns_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""200 with isAuthenticated=true → state dict."""
|
||
expected = {"user": {"isAuthenticated": True, "userId": 99}}
|
||
monkeypatch.setattr(
|
||
"app.services.cian_session.extract_state",
|
||
lambda html, mfe, key: expected,
|
||
)
|
||
result = _classify_verify_response(200, "<html>x</html>")
|
||
assert result == expected
|
||
|
||
|
||
def test_classify_200_state_missing_returns_markup_changed_sentinel(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""audit-scrapers finding 4: HTTP 200 но extract_state не нашёл auth-state
|
||
(Cian сменил вёрстку/MFE-схему header-frontend) → VERIFY_MARKUP_CHANGED_SENTINEL,
|
||
НЕ None. Раньше это конфлировалось с "cookies expired" (реальный логаут)."""
|
||
monkeypatch.setattr(
|
||
"app.services.cian_session.extract_state",
|
||
lambda html, mfe, key: None,
|
||
)
|
||
result = _classify_verify_response(200, "<html></html>")
|
||
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
|
||
assert result is not None # НЕ должно триггерить cookie-refresh alert
|
||
|
||
|
||
def test_classify_5xx_returns_source_unavailable_sentinel() -> None:
|
||
"""audit-scrapers finding 4: HTTP 500 (источник недоступен) →
|
||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None (cookies тут ни при чём)."""
|
||
result = _classify_verify_response(500, None)
|
||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||
assert result is not None
|
||
|
||
|
||
def test_classify_502_returns_source_unavailable_sentinel() -> None:
|
||
"""Любой non-200/403/401 статус (напр. 502 bad gateway) — источник недоступен."""
|
||
result = _classify_verify_response(502, None)
|
||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||
|
||
|
||
def test_classify_status_200_html_none_returns_source_unavailable_sentinel() -> None:
|
||
"""Defensive: status=200 но html=None (не должно случаться в проде, но
|
||
classifier не должен молча вернуть None='expired') → source-unavailable."""
|
||
result = _classify_verify_response(200, None)
|
||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||
|
||
|
||
def test_classify_403_is_distinct_from_401() -> None:
|
||
"""Ban and expired must not collapse to the same return value."""
|
||
ban = _classify_verify_response(403, None)
|
||
expired = _classify_verify_response(401, None)
|
||
assert ban is not expired
|
||
assert ban is not None
|
||
assert expired is None
|
||
|
||
|
||
def test_classify_all_four_outcomes_are_mutually_distinct() -> None:
|
||
"""audit-scrapers finding 4: expired (401) / ban (403) / source-unavailable (5xx)
|
||
/ markup-changed (200+extract_state=None) — четыре РАЗНЫХ сигнала, ни один не
|
||
коллапсирует в другой. Только expired (None) должен триггерить re-login alert."""
|
||
expired = _classify_verify_response(401, None)
|
||
ban = _classify_verify_response(403, None)
|
||
source_down = _classify_verify_response(500, None)
|
||
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("app.services.cian_session.extract_state", lambda html, mfe, key: None)
|
||
markup_changed = _classify_verify_response(200, "<html></html>")
|
||
|
||
outcomes = [expired, ban, source_down, markup_changed]
|
||
# None встречается ровно один раз (только expired) — остальные три truthy sentinel'а
|
||
# и все различны между собой (identity, не equality — это разные dict-объекты).
|
||
assert outcomes.count(None) == 1
|
||
assert expired is None
|
||
non_none = [o for o in outcomes if o is not None]
|
||
assert len(non_none) == 3
|
||
assert len({id(o) for o in non_none}) == 3
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# verify_session (async) — integration with curl_cffi mock
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_cffi_resp(status_code: int, text: str = "") -> MagicMock:
|
||
resp = MagicMock()
|
||
resp.status_code = status_code
|
||
resp.text = text
|
||
return resp
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_403_ban_not_treated_as_expired(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""HTTP 403 from curl_cffi → VERIFY_BAN_SENTINEL, never None."""
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(403))
|
||
|
||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||
result = await verify_session({"DMIR_AUTH": "x"})
|
||
|
||
assert result is VERIFY_BAN_SENTINEL
|
||
assert result is not None # must NOT trigger cookie-refresh alert
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_401_is_expired(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""HTTP 401 → None (genuine expiry)."""
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(401))
|
||
|
||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||
result = await verify_session({"DMIR_AUTH": "x"})
|
||
|
||
assert result is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_authenticated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""200 + isAuthenticated=true → returns state dict."""
|
||
expected_state = {"user": {"isAuthenticated": True, "userId": 102963817}}
|
||
monkeypatch.setattr(
|
||
"app.services.cian_session.extract_state",
|
||
lambda html, mfe, key: expected_state,
|
||
)
|
||
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html>x</html>"))
|
||
|
||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||
result = await verify_session({"DMIR_AUTH": "x", "_CIAN_GK": "y"})
|
||
|
||
assert result is not None
|
||
assert result["user"]["isAuthenticated"] is True
|
||
assert result["user"]["userId"] == 102963817
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_not_authenticated_returns_none(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""200 + isAuthenticated=false → None (expired)."""
|
||
monkeypatch.setattr(
|
||
"app.services.cian_session.extract_state",
|
||
lambda html, mfe, key: {"user": {"isAuthenticated": False, "userId": None}},
|
||
)
|
||
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html></html>"))
|
||
|
||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||
result = await verify_session({"DMIR_AUTH": "x"})
|
||
|
||
assert result is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_state_missing_returns_markup_changed_sentinel(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""audit-scrapers finding 4: 200 + extract_state returns None (markup changed)
|
||
→ VERIFY_MARKUP_CHANGED_SENTINEL, НЕ None. Раньше ложно триггерило "cookies
|
||
expired, please re-upload" для реальной причины "Cian сменил вёрстку"."""
|
||
monkeypatch.setattr(
|
||
"app.services.cian_session.extract_state",
|
||
lambda html, mfe, key: None,
|
||
)
|
||
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html></html>"))
|
||
|
||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||
result = await verify_session({"DMIR_AUTH": "x"})
|
||
|
||
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
|
||
assert result is not None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_5xx_returns_source_unavailable_sentinel() -> None:
|
||
"""audit-scrapers finding 4: HTTP 500 → VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
|
||
НЕ None. Источник временно недоступен — cookies тут ни при чём, вызывающий
|
||
не должен помечать сессию invalid / просить re-upload."""
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(500))
|
||
|
||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||
result = await verify_session({"DMIR_AUTH": "x"})
|
||
|
||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||
assert result is not None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_network_error_returns_source_unavailable_sentinel() -> None:
|
||
"""audit-scrapers finding 4: сетевой/транспортный сбой (timeout, connection
|
||
reset и т.п.) → VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None. Раньше generic
|
||
except возвращал None — конфлировал сетевой сбой с протухшими cookies."""
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(side_effect=ConnectionError("connection reset by peer"))
|
||
|
||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||
result = await verify_session({"DMIR_AUTH": "x"})
|
||
|
||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||
assert result is not None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_pool_exhausted_returns_source_unavailable_with_error_log(
|
||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
"""#2825 fail-closed (#2616): пул scrape_proxies исчерпан для cian (все узлы
|
||
забанены/нездоровы) — session.get НЕ вызывается (никуда не ходим без egress),
|
||
возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, но с ERROR-логом (не warning,
|
||
отдельным от обычного network-error пути) — явная деградация, а не проглатывание."""
|
||
from app.services.proxy_egress import ProxyPoolExhaustedError
|
||
|
||
def _raise(source: str) -> str | None:
|
||
raise ProxyPoolExhaustedError(
|
||
source, pool_total=2, banned_for_source=2, unhealthy_or_disabled=0
|
||
)
|
||
|
||
monkeypatch.setattr("app.services.cian_session.resolve_proxy_url_sync", _raise)
|
||
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200))
|
||
|
||
with (
|
||
patch("app.services.cian_session.AsyncSession", return_value=mock_session),
|
||
caplog.at_level("WARNING"),
|
||
):
|
||
result = await verify_session({"DMIR_AUTH": "x"})
|
||
|
||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||
mock_session.get.assert_not_called()
|
||
errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
|
||
assert any("пул прокси исчерпан" in rec.message.lower() for rec in errors)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verify_session_uses_kit_impersonate_profile() -> None:
|
||
"""AsyncSession строится с kit-профилем, а не с литералом.
|
||
|
||
Тест раньше прибивал гвоздём `chrome120` — то есть закреплял ровно тот дефект,
|
||
ради которого #3034 заводился: обновление TLS-профиля в kit ломало бы этот тест,
|
||
и «починка» состояла бы в возврате к устаревшему профилю.
|
||
"""
|
||
mock_session = AsyncMock()
|
||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(403))
|
||
|
||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session) as mock_cls:
|
||
await verify_session({"DMIR_AUTH": "x"})
|
||
|
||
_, kwargs = mock_cls.call_args
|
||
assert kwargs.get("impersonate") == DEFAULT_IMPERSONATE
|