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 3m31s
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / deploy (push) Successful in 2m9s
541 lines
27 KiB
Python
541 lines
27 KiB
Python
"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ.
|
||
|
||
Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь —
|
||
верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный
|
||
ответ означал «узел годен для Авито», а читался как «узел годен вообще».
|
||
|
||
Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация):
|
||
|
||
узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru
|
||
-----+----------+-------+---------------------+--------------+---------------------------
|
||
1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY
|
||
10 | any | 200 | 200 | 200 | 200
|
||
|
||
Что сторожится (каждый тест ниже падает на коде до фикса):
|
||
|
||
1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для
|
||
Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')`
|
||
выдавал его как ни в чём не бывало.
|
||
2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен
|
||
(`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной).
|
||
3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех.
|
||
4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и
|
||
забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что
|
||
«дешёвая ipify стирает браузерный вердикт» в #2723).
|
||
5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой
|
||
вердикт #2723 сохранён).
|
||
6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс).
|
||
7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
import pytest
|
||
import scraper_kit.browser_fetcher as bf
|
||
|
||
from app.services import proxy_pool
|
||
from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release
|
||
from tests.services.test_proxy_pool import FakeSession, _proxy
|
||
|
||
# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным.
|
||
_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = {
|
||
("http://u:p@h1:8080", "domclick"): (
|
||
False,
|
||
"proxy",
|
||
'{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}',
|
||
),
|
||
}
|
||
|
||
|
||
def _patch_probes(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
matrix: dict[tuple[str, str], tuple[bool, str | None, str]],
|
||
calls: list[tuple[str, str]] | None = None,
|
||
*,
|
||
default: tuple[bool, str | None, str] = (True, None, "html_len=16477"),
|
||
) -> None:
|
||
"""ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник)."""
|
||
|
||
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
|
||
return True, "1.2.3.4", 10, None
|
||
|
||
async def _fake_browser(
|
||
endpoint: str, proxy_url: str, **kw: Any
|
||
) -> tuple[bool, str | None, str]:
|
||
source = str(kw.get("source", "avito"))
|
||
if calls is not None:
|
||
calls.append((proxy_url, source))
|
||
return matrix.get((proxy_url, source), default)
|
||
|
||
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
|
||
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
|
||
monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser)
|
||
|
||
|
||
# ── 1. главное: вердикт разведён по источникам ───────────────────────────────
|
||
|
||
|
||
async def test_node_dead_for_domclick_is_not_issued_to_domclick(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём.
|
||
|
||
Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при
|
||
`browser_fail_streak=0` и свежей пробе.
|
||
"""
|
||
_patch_probes(monkeypatch, _LIVE_MATRIX)
|
||
db = FakeSession([_proxy(1), _proxy(2)])
|
||
|
||
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
# Домклику достаётся только исправный узел…
|
||
first = acquire(db, "domclick") # type: ignore[arg-type]
|
||
assert first is not None and first.id == 2
|
||
|
||
# …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить
|
||
# заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой
|
||
# заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0.
|
||
assert acquire(db, "domclick") is None, ( # type: ignore[arg-type]
|
||
"Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY"
|
||
)
|
||
|
||
# Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла.
|
||
lease = acquire(db, "avito") # type: ignore[arg-type]
|
||
assert lease is not None and lease.id == 1
|
||
|
||
ban = db._ban(1, "domclick")
|
||
assert ban is not None
|
||
assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба"
|
||
assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None
|
||
assert counters["pair_banned"] == 1
|
||
|
||
|
||
async def test_one_dead_platform_does_not_stain_the_node_globally(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает."""
|
||
_patch_probes(monkeypatch, _LIVE_MATRIX)
|
||
db = FakeSession([_proxy(1), _proxy(2)])
|
||
|
||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
row = db._by_id(1)
|
||
assert row["browser_unfit_since"] is None
|
||
assert row["browser_fail_streak"] == 0
|
||
assert row["enabled"] is True and row["consecutive_fails"] == 0
|
||
|
||
|
||
async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел)."""
|
||
dead_everywhere = {
|
||
("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable")
|
||
for src in ("avito", "cian", "yandex", "domclick")
|
||
}
|
||
_patch_probes(monkeypatch, dead_everywhere)
|
||
db = FakeSession([_proxy(1), _proxy(2)])
|
||
|
||
for _ in range(BROWSER_UNFIT_THRESHOLD):
|
||
db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение
|
||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
assert db._by_id(1)["browser_unfit_since"] is not None
|
||
assert db._by_id(2)["browser_unfit_since"] is None
|
||
|
||
|
||
# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("source", "must_contain", "path_suffix"),
|
||
[
|
||
("avito", "www.avito.ru", "/robots.txt"),
|
||
("cian", "ekb.cian.ru", "/robots.txt"),
|
||
("yandex", "realty.yandex.ru", "/robots.txt"),
|
||
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
|
||
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
|
||
#
|
||
# #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*,
|
||
# robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4
|
||
# в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по
|
||
# нему зелёная ровно тогда, когда свип получает блок.
|
||
("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"),
|
||
],
|
||
)
|
||
async def test_probe_asks_the_working_host_of_each_source(
|
||
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str
|
||
) -> None:
|
||
seen: dict[str, Any] = {}
|
||
# Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» —
|
||
# 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа.
|
||
_body = (
|
||
'{"result":{"snippetsCount":678}}' if source == "domclick" else "<pre>User-agent: *</pre>"
|
||
)
|
||
|
||
class _Resp:
|
||
status_code = 200
|
||
text = "{}"
|
||
|
||
@staticmethod
|
||
def json() -> dict[str, str]:
|
||
return {"html": _body}
|
||
|
||
class _Client:
|
||
def __init__(self, **_kw: Any) -> None: ...
|
||
|
||
async def __aenter__(self) -> _Client:
|
||
return self
|
||
|
||
async def __aexit__(self, *_: object) -> None:
|
||
return None
|
||
|
||
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
|
||
seen["payload"] = json
|
||
return _Resp()
|
||
|
||
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
|
||
ok, _fail_kind, _detail = await bf.probe_proxy_via_browser(
|
||
"http://tradein-browser:3000", "http://u:p@node:8080", source=source
|
||
)
|
||
|
||
assert ok is True
|
||
assert must_contain in seen["payload"]["url"]
|
||
# #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это
|
||
# боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть
|
||
# нагрузка на площадку остаётся минимальной, а защита — той же, что у работы.
|
||
assert path_suffix in seen["payload"]["url"]
|
||
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
|
||
assert seen["payload"]["source"] == "generic"
|
||
|
||
|
||
async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы."""
|
||
stub = "<!DOCTYPE html><html><head><title>Ошибка - Циан</title></head><body>…</body></html>"
|
||
|
||
class _Resp:
|
||
status_code = 200
|
||
text = "{}"
|
||
|
||
@staticmethod
|
||
def json() -> dict[str, str]:
|
||
return {"html": stub}
|
||
|
||
class _Client:
|
||
def __init__(self, **_kw: Any) -> None: ...
|
||
|
||
async def __aenter__(self) -> _Client:
|
||
return self
|
||
|
||
async def __aexit__(self, *_: object) -> None:
|
||
return None
|
||
|
||
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
|
||
return _Resp()
|
||
|
||
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
|
||
ok, fail_kind, detail = await bf.probe_proxy_via_browser(
|
||
"http://tradein-browser:3000", "http://u:p@node:8080", source="cian"
|
||
)
|
||
|
||
assert ok is False
|
||
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
|
||
assert fail_kind == "page"
|
||
# #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от
|
||
# площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика).
|
||
# Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили.
|
||
assert "no marker 'User-agent'" in detail
|
||
|
||
|
||
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя."""
|
||
_patch_probes(
|
||
monkeypatch,
|
||
{("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")},
|
||
)
|
||
db = FakeSession([_proxy(1), _proxy(2)])
|
||
|
||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
assert db._ban(1, "cian") is not None
|
||
assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём
|
||
|
||
|
||
# ── 4. проба снимает только свою строку ──────────────────────────────────────
|
||
|
||
|
||
async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором.
|
||
|
||
robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить
|
||
дорогой вердикт значило бы повторить #2723 на паре.
|
||
"""
|
||
later = datetime.now(UTC) + timedelta(hours=6)
|
||
db = FakeSession(
|
||
[_proxy(1), _proxy(2)],
|
||
bans=[
|
||
{
|
||
"proxy_id": 1,
|
||
"source": "avito",
|
||
"banned_until": later,
|
||
"ban_count": 1,
|
||
"reason": "banned:avito", # распознан боевым сбором (капча/QRATOR)
|
||
},
|
||
{
|
||
"proxy_id": 1,
|
||
"source": "cian",
|
||
"banned_until": later,
|
||
"ban_count": 1,
|
||
"reason": "probe:browser", # прошлый вердикт самой пробы
|
||
},
|
||
],
|
||
)
|
||
_patch_probes(monkeypatch, {}) # все площадки отвечают
|
||
|
||
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
avito = db._ban(1, "avito")
|
||
assert avito is not None, "чужой бан проба снимать не имеет права"
|
||
assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать"
|
||
assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять"
|
||
assert counters["pair_cleared"] == 1
|
||
|
||
|
||
# ── 4b. …и не присваивает чужую (дефект #2803, реализовался на проде) ─────────
|
||
|
||
|
||
async def test_probe_does_not_steal_a_live_ban(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Упавшая проба НЕ переписывает активный бан, поставленный боевым сбором.
|
||
|
||
Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21` после
|
||
упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. Фильтр «снимаю только
|
||
своё» при этом цел, но защищать перестаёт: присвоенная строка уже «своя», и
|
||
следующая успешная проба сняла бы ею бан, который площадка поставила по-настоящему.
|
||
Плюс сама метка перестаёт быть свидетельством («нас отбили» неотличимо от «мы не
|
||
смогли», #2764), а ban_count складывает события разного рода в одну эскалацию —
|
||
отдых пары вырос с 6 ч до 12 ч.
|
||
"""
|
||
until = datetime.now(UTC) + timedelta(hours=6)
|
||
db = FakeSession(
|
||
[_proxy(1), _proxy(2)],
|
||
bans=[
|
||
{
|
||
"proxy_id": 1,
|
||
"source": "cian",
|
||
"banned_until": until,
|
||
"ban_count": 1,
|
||
"reason": "banned:cian", # боевой сбор: Циан отдал заглушку
|
||
}
|
||
],
|
||
)
|
||
_patch_probes(
|
||
monkeypatch,
|
||
{("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")},
|
||
)
|
||
|
||
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
ban = db._ban(1, "cian")
|
||
assert ban is not None
|
||
assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора"
|
||
assert ban["ban_count"] == 1, "два события разного рода посчитаны одной эскалацией"
|
||
assert ban["banned_until"] == until, "чужой срок проба не пересчитывает (может и укоротить)"
|
||
assert counters["pair_banned"] == 0, "счётчик не должен объявлять баном то, чего не записал"
|
||
|
||
|
||
async def test_live_ban_takes_over_the_probe_row(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Зеркало намеренно НЕ симметрично: боевой сбор строку пробы перехватывает.
|
||
|
||
Его вердикт сильнее — площадка отбила нас именно сейчас, — пара остаётся забаненной,
|
||
а метка становится точнее. Если запретить и ему, строка останется за пробой, и её же
|
||
зелёный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально.
|
||
"""
|
||
db = FakeSession(
|
||
[_proxy(1), _proxy(2)],
|
||
bans=[
|
||
{
|
||
"proxy_id": 1,
|
||
"source": "cian",
|
||
"banned_until": datetime.now(UTC) + timedelta(hours=6),
|
||
"ban_count": 1,
|
||
"reason": "probe:browser",
|
||
}
|
||
],
|
||
)
|
||
|
||
proxy_pool.mark_banned(db, 1, source="cian") # type: ignore[arg-type]
|
||
|
||
assert db._ban(1, "cian")["reason"] == "banned:cian"
|
||
|
||
# …и с этой минуты зелёная проба его не снимет — ради чего перехват и нужен.
|
||
_patch_probes(monkeypatch, {})
|
||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
assert db._ban(1, "cian") is not None
|
||
|
||
|
||
# ── 5-6. чужие отказы ────────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Лежащий сайдкар не должен забанить разом все пары (#2686-класс)."""
|
||
down = {
|
||
(f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError")
|
||
for pid in (1, 2)
|
||
for src in ("avito", "cian", "yandex", "domclick")
|
||
}
|
||
_patch_probes(monkeypatch, down)
|
||
db = FakeSession([_proxy(1), _proxy(2)])
|
||
|
||
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
assert counters["pair_banned"] == 0
|
||
assert db.bans == []
|
||
assert db._by_id(1)["browser_unfit_since"] is None
|
||
|
||
|
||
# ── 7. цена такта ────────────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_probe_cost_is_nodes_times_servable_sources(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Крест ограничен источниками, которым узел вообще может достаться.
|
||
|
||
Диагностика не должна превращаться в нагрузку: узел с выделенной affinity
|
||
спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника
|
||
= 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на
|
||
площадку в сутки против ~1000 боевых /fetch.
|
||
"""
|
||
calls: list[tuple[str, str]] = []
|
||
_patch_probes(monkeypatch, {}, calls)
|
||
db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")])
|
||
|
||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [
|
||
"avito",
|
||
"cian",
|
||
"domclick",
|
||
"yandex",
|
||
]
|
||
assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"]
|
||
assert len(calls) == 5
|
||
|
||
|
||
async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки.
|
||
|
||
Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из
|
||
теста выше не меняется, пока всё зелено.
|
||
"""
|
||
calls: list[tuple[str, str]] = []
|
||
_patch_probes(monkeypatch, _LIVE_MATRIX, calls)
|
||
db = FakeSession([_proxy(1), _proxy(2)])
|
||
|
||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
assert calls.count(("http://u:p@h1:8080", "domclick")) == 2
|
||
assert calls.count(("http://u:p@h1:8080", "avito")) == 1
|
||
|
||
|
||
async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Один провал, второй заход зелёный → бан пары не пишется."""
|
||
attempts: dict[str, int] = {}
|
||
|
||
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
|
||
return True, "1.2.3.4", 10, None
|
||
|
||
async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]:
|
||
source = str(kw.get("source", "avito"))
|
||
key = f"{proxy_url}|{source}"
|
||
attempts[key] = attempts.get(key, 0) + 1
|
||
if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1:
|
||
return False, "proxy", "503 browser unavailable"
|
||
return True, None, "html_len=150"
|
||
|
||
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
|
||
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
|
||
monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky)
|
||
db = FakeSession([_proxy(1), _proxy(2)])
|
||
|
||
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||
|
||
assert counters["pair_banned"] == 0
|
||
assert db._ban(1, "domclick") is None
|
||
lease = acquire(db, "domclick") # type: ignore[arg-type]
|
||
assert lease is not None
|
||
release(db, lease.id) # type: ignore[arg-type]
|
||
|
||
|
||
# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ──
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("api_body", "expect_ok"),
|
||
[
|
||
# Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом.
|
||
# На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True,
|
||
# то есть «пара здорова» ровно там, где свип получает блок. Красный на main.
|
||
("<html><title>Ошибка</title>qrator captcha</html>", False),
|
||
# Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность
|
||
# ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай
|
||
# и провалила бы этот, то есть тест не может позеленеть от глухого отказа.
|
||
# На origin/main этот случай тоже красный, но по другой причине — там проба
|
||
# спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА.
|
||
('{"result":{"snippetsCount":678}}', True),
|
||
],
|
||
)
|
||
async def test_domclick_probe_sees_the_block_that_robots_txt_hides(
|
||
monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool
|
||
) -> None:
|
||
"""Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок.
|
||
|
||
Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` → 05:02 свип
|
||
`QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и
|
||
5 банов на все четыре площадки при ежедневном блоке Домклика.
|
||
"""
|
||
seen: dict[str, Any] = {}
|
||
|
||
class _Resp:
|
||
status_code = 200
|
||
text = "{}"
|
||
|
||
def __init__(self, body: str) -> None:
|
||
self._body = body
|
||
|
||
def json(self) -> dict[str, str]:
|
||
return {"html": self._body}
|
||
|
||
class _Client:
|
||
def __init__(self, **_kw: Any) -> None: ...
|
||
|
||
async def __aenter__(self) -> _Client:
|
||
return self
|
||
|
||
async def __aexit__(self, *_: object) -> None:
|
||
return None
|
||
|
||
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
|
||
seen["payload"] = json
|
||
asked = str(json["url"])
|
||
# Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт.
|
||
if asked.endswith("/robots.txt"):
|
||
return _Resp("<pre>User-agent: *</pre>")
|
||
return _Resp(api_body)
|
||
|
||
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
|
||
ok, fail_kind, _detail = await bf.probe_proxy_via_browser(
|
||
"http://tradein-browser:3000", "http://u:p@node:8080", source="domclick"
|
||
)
|
||
|
||
assert ok is expect_ok
|
||
if not expect_ok:
|
||
# Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy".
|
||
assert fail_kind == "page"
|
||
# Спрашивали именно боевой путь, а не robots.txt того же хоста.
|
||
assert "/api/offers/" in seen["payload"]["url"]
|