gendesign/tradein-mvp/backend/tests/test_cian_session.py
lekss361 35f5c3426b
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 4m50s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m34s
fix(tradein/scrapers): детекция дрейфа разметки вместо тихой пустоты (#2535)
2026-07-26 22:33:54 +00:00

439 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for cian_session — cookie management service."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
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
# ---------------------------------------------------------------------------
# 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_uses_chrome120_impersonate() -> None:
"""curl_cffi AsyncSession must be constructed with impersonate='chrome120'."""
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") == "chrome120"