"""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, _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, "") 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, "x") assert result == expected def test_classify_200_state_missing_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: """200 but extract_state returns None → None.""" monkeypatch.setattr( "app.services.cian_session.extract_state", lambda html, mfe, key: None, ) result = _classify_verify_response(200, "") assert result is None 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 # --------------------------------------------------------------------------- # 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, "x")) 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, "")) 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_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """200 + extract_state returns None → None.""" 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, "")) 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_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"