gendesign/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py
bot-backend 8761602e9b
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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) Successful in 5m5s
chore(tradein/proxy): последние две ручки admin.py — через фабрику фетчера; снят форс pool-режима у cian-history (#3197, #3386)
Два хвоста одной темы — проводка пула прокси в контейнере backend.

#3197: `cian-login` и `domclick-detail-debug` были последними прямыми
конструкциями `BrowserFetcher(source=, endpoint=)` мимо `build_browser_fetcher`.
Без `proxy_provider`/`use_pool`/`environment` сайдкар брал свой env-узел
`SCRAPER_PROXY_URL` (на проде выключенный узел 9: 407 → camoufox `InvalidIP`), а
прод-отказ «пул пуст» (#2616) на этих путях был мёртв — он смотрит на
`environment`, который до конструктора не доезжал. Соседи по эпику уже переведены
(#3382 cian, #3389 yandex). Прямых конструкций без провайдера вне тестов больше
не осталось: остальные (backfill-задачи, pipeline) пул получают своими kwargs,
а `endpoint=None`-ветки providers — это документированный `config=None` для
офлайн-тестов.

#3386: `_PoolCurlConfig` в `cian_price_history` форсил `use_proxy_pool_curl=True`,
потому что у контейнера `backend` не было переменной. #3387 задал
`USE_PROXY_POOL_CURL: "true"` сервису `backend` в compose — зашитая константа
стала лишней и делала рубильник неотключаемым ровно на этом пути (докстринг при
этом описывал уже неверную причину). Теперь `RealScraperConfig()` напрямую.

Тесты меряют значения, а не наличие kwarg'а: на откате исходников красные
4 параметризации нового `test_3197_admin_debug_browser_pool_wiring`
(`assert None is not None` — провайдер не передан) и
`test_price_history_honours_flag_off` (`assert ['cian'] == []` — пул дёргался при
выключенном флаге).
2026-09-06 17:22:46 +05:00

294 lines
12 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.

"""Offline tests для Cian cookie-session admin-эндпоинтов (#инцидент 2026-08-10).
cian_session_svc.verify_session() возвращает 5 разных исходов (state dict / None /
VERIFY_BAN_SENTINEL / VERIFY_SOURCE_UNAVAILABLE_SENTINEL / VERIFY_MARKUP_CHANGED_SENTINEL)
и раньше admin.py проверял только `if state is None`, из-за чего реальный бан по IP
(403) и недоступность источника (5xx) выглядели как "куки протухли" — человек в
момент инцидента перезаливал заведомо валидные куки вместо починки egress/прокси.
Покрытие 3 эндпоинтов (db/verify_session/build_browser_fetcher мокаются, NO live network/DB),
зеркалит паттерн test_domclick_admin_apis.py (dependency_overrides[get_db] + TestClient):
- POST /api/v1/admin/scrape/cian/upload-cookies
- POST /api/v1/admin/scrape/cian/auto-login
- GET /api/v1/admin/scrape/cian/test-auth
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.services.cian_session import (
VERIFY_BAN_SENTINEL,
VERIFY_MARKUP_CHANGED_SENTINEL,
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
)
@pytest.fixture
def client() -> TestClient:
from app.api.v1 import admin as admin_module
from app.core.db import get_db
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
def fake_db():
yield MagicMock()
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
_UPLOAD_URL = "/api/v1/admin/scrape/cian/upload-cookies"
_AUTOLOGIN_URL = "/api/v1/admin/scrape/cian/auto-login"
_TEST_AUTH_URL = "/api/v1/admin/scrape/cian/test-auth"
_RAW_COOKIES = {"DMIR_AUTH": "abc", "_CIAN_GK": "def"}
_AUTHENTICATED_STATE = {"user": {"isAuthenticated": True, "userId": 102963817}}
_AUTHENTICATED_STATE_NO_USERID = {"user": {"isAuthenticated": True}}
# ── POST /scrape/cian/upload-cookies — пять исходов verify_session ────────────
def test_upload_cookies_ban_returns_503(client: TestClient) -> None:
"""403 TLS/bot ban → 503, текст говорит чинить прокси, НЕ перезаливать куки."""
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
),
):
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
assert resp.status_code == 503
detail = resp.json()["detail"]
assert "заблокировал" in detail
assert "прокси" in detail or "egress" in detail
assert "бесполезно" in detail
# Текст обязан называть ЖИВУЮ переменную: инцидент 2026-08-10 — правка мёртвой
# CIAN_PROXY_URL не давала эффекта, реальный egress задаётся SCRAPER_PROXY_URL.
assert "SCRAPER_PROXY_URL" in detail
def test_upload_cookies_source_unavailable_returns_503(client: TestClient) -> None:
"""5xx/сеть → 503, текст говорит повторить позже, куки не трогать."""
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL),
),
):
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
assert resp.status_code == 503
detail = resp.json()["detail"]
assert "недоступен" in detail
assert "позже" in detail
def test_upload_cookies_markup_changed_returns_500(client: TestClient) -> None:
"""200 но auth-state не распарсился → 500, текст указывает на фикс парсера."""
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL),
),
):
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
assert resp.status_code == 500
detail = resp.json()["detail"]
assert "вёрстк" in detail
assert "перезалив" in detail
def test_upload_cookies_expired_returns_401(client: TestClient) -> None:
"""None → 401, текст просит перезалить куки (единственный случай re-upload)."""
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=None),
),
):
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
assert resp.status_code == 401
assert "перезалейте" in resp.json()["detail"]
def test_upload_cookies_success_missing_userid_returns_400(client: TestClient) -> None:
"""Успешный state, но без userId → 400 с уточнением что куки тут ни при чём."""
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=_AUTHENTICATED_STATE_NO_USERID),
),
):
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "userId" in detail
assert "куки тут ни при" in detail
def test_upload_cookies_success_saves_and_returns_200(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
),
patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save,
):
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["userId"] == 102963817
mock_save.assert_called_once()
_, kwargs = mock_save.call_args
assert kwargs["account_user_id"] == 102963817
# ── POST /scrape/cian/auto-login — тот же маппинг после успешного browser-login ──
def _mock_browser_fetcher(raw_cookies: dict[str, str]) -> MagicMock:
fetcher = AsyncMock()
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
fetcher.__aexit__ = AsyncMock(return_value=None)
fetcher.login = AsyncMock(return_value=raw_cookies)
return fetcher
def test_auto_login_ban_returns_503(client: TestClient) -> None:
"""Browser login прошёл, но verify_session ловит 403 ban → 503, не "куки протухли"."""
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"),
patch("app.api.v1.admin.settings.cian_login_password", "secret"),
patch(
# #3197 (хвост): ручка строит фетчер фабрикой, а не BrowserFetcher напрямую —
# только так до сайдкара доезжают proxy_provider/use_pool/environment.
"app.api.v1.admin.build_browser_fetcher",
return_value=_mock_browser_fetcher(_RAW_COOKIES),
),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
),
):
resp = client.post(_AUTOLOGIN_URL)
assert resp.status_code == 503
assert "заблокировал" in resp.json()["detail"]
def test_auto_login_success_saves_and_returns_200(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"),
patch("app.api.v1.admin.settings.cian_login_password", "secret"),
patch(
# #3197 (хвост): ручка строит фетчер фабрикой, а не BrowserFetcher напрямую —
# только так до сайдкара доезжают proxy_provider/use_pool/environment.
"app.api.v1.admin.build_browser_fetcher",
return_value=_mock_browser_fetcher(_RAW_COOKIES),
),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
),
patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save,
):
resp = client.post(_AUTOLOGIN_URL)
assert resp.status_code == 200
assert resp.json()["userId"] == 102963817
mock_save.assert_called_once()
# ── GET /scrape/cian/test-auth — reason различает все пять исходов ────────────
def test_test_auth_ban_reason(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
),
):
resp = client.get(_TEST_AUTH_URL)
assert resp.status_code == 200
body = resp.json()
assert body["authenticated"] is False
assert body["reason"] == "banned_403"
def test_test_auth_source_unavailable_reason(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL),
),
):
resp = client.get(_TEST_AUTH_URL)
assert resp.status_code == 200
assert resp.json()["reason"] == "source_unavailable"
def test_test_auth_markup_changed_reason(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL),
),
):
resp = client.get(_TEST_AUTH_URL)
assert resp.status_code == 200
assert resp.json()["reason"] == "markup_changed"
def test_test_auth_expired_reason(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=None),
),
):
resp = client.get(_TEST_AUTH_URL)
assert resp.status_code == 200
assert resp.json()["reason"] == "session_expired_or_invalid"
def test_test_auth_success(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
patch(
"app.api.v1.admin.cian_session_svc.verify_session",
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
),
):
resp = client.get(_TEST_AUTH_URL)
assert resp.status_code == 200
body = resp.json()
assert body["authenticated"] is True
assert body["userId"] == 102963817
assert body["reason"] is None