gendesign/tradein-mvp/backend/tests/test_admin_proxies.py
bot-backend b89788ee99 feat(tradein/proxy): прогон знает свой узел, а снятый бан перестаёт стирать историю (#3404)
Выбор оператора мобильного прокси опирался на две ненадёжные опоры.

Первая: `scrape_runs` не знала, через какой узел шёл прогон — колонка `proxy_id`
была только у банов и ротаций. «Какой узел собрал 5 карточек из 21» не выяснялось
ни одним запросом.

Вторая: `clear_source_bans` делала DELETE, а зовётся она после КАЖДОЙ успешной
ротации exit-IP. У #540723 (МегаФон) 23 успешные ротации и ноль строк банов,
у #540722 (Tele2) ротаций почти не было и 7 банов. «7 против 0» читалось как
«Tele2 хуже», хотя в той же мере это «у МегаФона историю стёрли 23 раза».

Теперь:
- `scrape_runs.proxy_id` — последний выданный прогону узел; полная цепочка
  (если узел менялся mid-run) копится в `counters.proxy_ids`. Пишет
  `proxy_pool.attribute_run_proxy` из единственной точки — сразу после выдачи
  лиза в `acquire()`, поэтому curl-путь, браузерный sticky lease и ре-acquire
  при ротации покрыты одинаково. `run_id` доходит до адаптера через ContextVar
  (`scraper_kit.orchestration.run_context`): протокол `ProxyProvider.acquire`
  его не несёт, а `RealProxyProvider` живёт одним объектом на весь планировщик.
  Best-effort: `lock_timeout` 2с и проглоченное исключение — диагностика не
  вправе ронять выдачу прокси или ждать на блокировке строки прогона.
- `clear_source_bans` гасит строку (`banned_until = now()`, `ban_count = 0`,
  `cleared_at`/`cleared_reason`) вместо удаления. Эскалация сохраняется 1:1:
  формула в `mark_banned` берёт ПРЕДЫДУЩИЙ `ban_count` показателем степени, при
  нуле это ровно `SOURCE_BAN_BASE_HOURS` — как после DELETE. Строка доживает до
  штатного purge по `SOURCE_BAN_PURGE_DAYS`.

Для всех читателей `scrape_proxy_source_bans` погашенная строка неотличима от
отсутствующей: acquire, оба guard-подзапроса `mark_banned`, `proxy_egress`
(ранжирование по `ban_count` даёт 0, как у узла без истории), admin `_active_ban` —
все гейтятся по `banned_until > now()`.

Ничего не бэкфиллится: связать прошедшие прогоны с узлами нечем (`leased_by`
исторически = NON_RUN_LEASE_MARKER), врать восстановленным значением нельзя.

Миграция 287. Тесты: 9 новых на обе части (главный — эскалация после гашения даёт
базовые 6ч, а не удвоенные) + 14 существующих переведены с DELETE-семантики на
гашение, включая проверку, что секрет ротации не утекает в новое `cleared_reason`.
Полный прогон бэкенда: 5600 passed, 37 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WHFxVPWoBnSZihkdH1Uou
2026-09-06 13:05:20 +03:00

382 lines
16 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-тесты admin proxy-pool ручек (#2161, #2610).
Покрытие (db мокается, NO live network/DB):
- POST /api/v1/admin/proxies/bulk — UPSERT-счётчики, валидация affinity/kind
- GET /api/v1/admin/proxies — маскировка пароля, фильтры, disabled_reason в ответе,
активные баны по источникам (#2600 п.2 — узел бывает enabled=true и при этом не
выдаётся конкретному источнику)
- PATCH /api/v1/admin/proxies/{id} — enable/disable, 404
- #2610: PATCH enabled=false ставит disabled_reason (ручное выключение отличимо от
авто); PATCH enabled=true сбрасывает disabled_reason в NULL (снова авто-восстанавливаем)
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@pytest.fixture
def db() -> MagicMock:
return MagicMock()
@pytest.fixture
def client(db: MagicMock) -> 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() -> Any:
yield db
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
def _scalar_result(value: object) -> MagicMock:
res = MagicMock()
res.scalar.return_value = value
return res
def _cleared_bans_result(rows: list[dict[str, Any]] | None = None) -> MagicMock:
"""Ответ на UPDATE ... RETURNING source (proxy_pool.clear_source_bans, #3404: гасит
строку — banned_until=now()/ban_count=0/cleared_at/cleared_reason, — а не DELETE).
PATCH enabled=true снимает баны узла по источникам — «ручное включение = чистый
лист», как и обнуление disabled_reason рядом.
"""
res = MagicMock()
res.fetchall.return_value = [type("Row", (), r)() for r in (rows or [])]
return res
def _bans_result(rows: list[dict[str, Any]] | None = None) -> MagicMock:
"""Ответ на ВТОРОЙ execute в /proxies-ручках — активные баны по источникам (#2600 п.2).
list_proxies/patch_proxy после основного запроса дочитывают scrape_proxy_source_bans
(_fetch_source_bans), поэтому мок обязан отдавать два разных результата по порядку.
"""
res = MagicMock()
res.mappings.return_value.all.return_value = rows or []
return res
# ── _mask_proxy_url unit ─────────────────────────────────────────────────────
def test_mask_proxy_url_hides_password() -> None:
from app.api.v1.admin import _mask_proxy_url
assert _mask_proxy_url("socks5://user:secret@host:1080") == "socks5://user:***@host:1080"
assert _mask_proxy_url("http://u:p@1.2.3.4:8080") == "http://u:***@1.2.3.4:8080"
def test_mask_proxy_url_no_password_untouched() -> None:
from app.api.v1.admin import _mask_proxy_url
assert _mask_proxy_url("http://host:8080") == "http://host:8080"
assert _mask_proxy_url(None) is None
assert _mask_proxy_url("") == ""
# ── POST /proxies/bulk ───────────────────────────────────────────────────────
def test_bulk_upsert_counts_inserted_and_updated(client: TestClient, db: MagicMock) -> None:
# первая строка вставлена (xmax=0 → True), вторая обновлена (False)
db.execute.side_effect = [_scalar_result(True), _scalar_result(False)]
r = client.post(
"/api/v1/admin/proxies/bulk",
json={
"proxies": [
{"url": "http://u:p@host1:8080", "provider_affinity": "avito"},
{"url": "http://u:p@host2:8080", "provider_affinity": "cian", "kind": "socks5"},
]
},
)
assert r.status_code == 200, r.text
assert r.json() == {"inserted": 1, "updated": 1}
assert db.commit.called
def test_bulk_invalid_affinity_422(client: TestClient) -> None:
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://host:8080", "provider_affinity": "garbage"}]},
)
assert r.status_code == 422
def test_bulk_invalid_kind_422(client: TestClient) -> None:
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://host:8080", "kind": "ftp"}]},
)
assert r.status_code == 422
def test_bulk_empty_list_422(client: TestClient) -> None:
r = client.post("/api/v1/admin/proxies/bulk", json={"proxies": []})
assert r.status_code == 422
def test_bulk_defaults_affinity_any(client: TestClient, db: MagicMock) -> None:
db.execute.side_effect = [_scalar_result(True)]
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://host:8080"}]},
)
assert r.status_code == 200
# affinity по умолчанию 'any' попал в параметры INSERT
_, kwargs = db.execute.call_args
assert kwargs is not None
params = db.execute.call_args.args[1]
assert params["aff"] == "any"
assert params["kind"] == "http"
# ── GET /proxies ─────────────────────────────────────────────────────────────
def _proxy_db_row(**over: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": 1,
"label": "test",
"url": "socks5://user:secret@host:1080",
"kind": "socks5",
"provider_affinity": "avito",
"rotate_url": "http://user:secret@rot:9000/changeip",
"enabled": True,
"disabled_reason": None,
"consecutive_fails": 0,
"exit_ip": "1.2.3.4",
"latency_ms": 120,
"last_check_at": datetime(2026, 7, 1, tzinfo=UTC),
"last_ok_at": datetime(2026, 7, 1, tzinfo=UTC),
"leased_by": None,
"leased_at": None,
"geo": "RU",
"operator": "beeline",
"expires_at": None,
"created_at": datetime(2026, 6, 1, tzinfo=UTC),
"updated_at": datetime(2026, 7, 1, tzinfo=UTC),
}
base.update(over)
return base
def test_list_masks_password(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.all.return_value = [_proxy_db_row()]
db.execute.side_effect = [result, _bans_result()]
r = client.get("/api/v1/admin/proxies")
assert r.status_code == 200, r.text
rows = r.json()
assert len(rows) == 1
assert rows[0]["url"] == "socks5://user:***@host:1080"
assert rows[0]["rotate_url"] == "http://user:***@rot:9000/changeip"
assert "secret" not in r.text
def test_list_exposes_disabled_reason(client: TestClient, db: MagicMock) -> None:
"""(#2610) Оператор должен видеть причину ручного выключения в листинге."""
result = MagicMock()
result.mappings.return_value.all.return_value = [
_proxy_db_row(id=1, enabled=False, disabled_reason=None),
_proxy_db_row(id=2, enabled=False, disabled_reason="забанен Авито"),
]
db.execute.side_effect = [result, _bans_result()]
r = client.get("/api/v1/admin/proxies")
assert r.status_code == 200, r.text
rows = {row["id"]: row for row in r.json()}
assert rows[1]["disabled_reason"] is None # авто-выключен
assert rows[2]["disabled_reason"] == "забанен Авито" # выключен вручную
def test_list_exposes_active_source_bans(client: TestClient, db: MagicMock) -> None:
"""(#2600 п.2) Узел enabled=true, но забанен Авито — оператор должен видеть, почему
он не выдаётся конкретному источнику; для остальных источников узел в строю."""
result = MagicMock()
result.mappings.return_value.all.return_value = [
_proxy_db_row(id=1, enabled=True),
_proxy_db_row(id=2, enabled=True),
]
db.execute.side_effect = [
result,
_bans_result(
[
{
"proxy_id": 1,
"source": "avito",
"banned_until": datetime(2026, 8, 5, 12, tzinfo=UTC),
"ban_count": 2,
}
]
),
]
r = client.get("/api/v1/admin/proxies")
assert r.status_code == 200, r.text
rows = {row["id"]: row for row in r.json()}
assert rows[1]["source_bans"] == [
{"source": "avito", "banned_until": "2026-08-05T12:00:00+00:00", "ban_count": 2}
]
assert rows[2]["source_bans"] == [] # чистый узел — пустой список, а не отсутствие поля
def test_list_passes_filters(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.all.return_value = []
db.execute.return_value = result
r = client.get("/api/v1/admin/proxies", params={"provider": "cian", "enabled": "false"})
assert r.status_code == 200
params = db.execute.call_args.args[1]
assert params["provider"] == "cian"
assert params["enabled"] is False
# ── PATCH /proxies/{id} ──────────────────────────────────────────────────────
def test_patch_disable(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(enabled=False)
db.execute.side_effect = [result, _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False})
assert r.status_code == 200, r.text
assert r.json()["enabled"] is False
assert db.commit.called
def test_patch_not_found_404(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.fetchone.return_value = None
db.execute.return_value = result
r = client.patch("/api/v1/admin/proxies/999", json={"enabled": True})
assert r.status_code == 404
# ── PATCH /proxies/{id} — disabled_reason (#2610) ───────────────────────────
def test_patch_disable_sets_disabled_reason_default(client: TestClient, db: MagicMock) -> None:
"""(#2610) Ручное выключение без явной причины — ставится дефолтный текст."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=False, disabled_reason="manually disabled via admin API"
)
db.execute.side_effect = [result, _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] == "manually disabled via admin API"
# дефолтная причина реально передана в SQL как fallback-параметр (первый execute —
# сам UPDATE; второй, #2600 п.2, дочитывает активные баны по источникам)
params = db.execute.call_args_list[0].args[1]
assert params["default_reason"]
assert params["reason"] is None
def test_patch_disable_sets_disabled_reason_custom(client: TestClient, db: MagicMock) -> None:
"""(#2610) Оператор передаёт свою причину — она уходит в SQL-параметры."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=False, disabled_reason="забанен Авито"
)
db.execute.side_effect = [result, _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False, "reason": "забанен Авито"})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] == "забанен Авито"
params = db.execute.call_args_list[0].args[1]
assert params["reason"] == "забанен Авито"
def test_patch_enable_clears_disabled_reason(client: TestClient, db: MagicMock) -> None:
"""(#2610, red/green c) Ручное включение сбрасывает disabled_reason → узел снова
авто-восстанавливаем через mark_health."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=True, disabled_reason=None
)
db.execute.side_effect = [result, _cleared_bans_result(), _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": True})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] is None
params = db.execute.call_args_list[0].args[1]
assert params["enabled"] is True
def test_patch_enable_clears_source_bans(client: TestClient, db: MagicMock) -> None:
"""(#2600 п.2) Ручное включение = чистый лист: снимаются и per-source баны, иначе у
оператора нет способа отменить ложный бан (детектор капчи, #2642) — узел был бы
enabled=true и всё равно невыдаваемым источнику до 72 часов."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=True, disabled_reason=None
)
db.execute.side_effect = [result, _cleared_bans_result([{"source": "avito"}]), _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": True})
assert r.status_code == 200, r.text
clear_sql = str(db.execute.call_args_list[1].args[0])
# #3404: гашение (UPDATE ... SET cleared_reason=...), а не DELETE — строка остаётся
# для трассируемости, но перестаёт блокировать выдачу.
assert "UPDATE scrape_proxy_source_bans" in clear_sql
assert "cleared_reason" in clear_sql
assert db.execute.call_args_list[1].args[1]["proxy_id"] == 1
def test_patch_disable_keeps_source_bans(client: TestClient, db: MagicMock) -> None:
"""Выключение узла бан-строки НЕ снимает — снятие это «оператор говорит, что узел
в порядке», а выключение утверждает обратное."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(enabled=False)
db.execute.side_effect = [result, _bans_result()]
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False})
assert r.status_code == 200, r.text
# #3404: clear_source_bans теперь UPDATE, а не DELETE — ищем по cleared_reason,
# уникальному для этого запроса маркеру.
assert not any(
"cleared_reason" in str(c.args[0]) for c in db.execute.call_args_list
)
# ── POST /proxies/bulk — не глушит ручной disable (#2610) ──────────────────
def test_bulk_upsert_sql_preserves_manual_disable_on_conflict(
client: TestClient, db: MagicMock
) -> None:
"""Bulk-upsert (например повторный прогон загрузчика) не должен молча вернуть в
строй узел, выключенный оператором вручную — ON CONFLICT SET enabled должен
проверять disabled_reason существующей строки, а не ставить true безусловно."""
db.execute.side_effect = [_scalar_result(False)]
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://u:p@host1:8080", "provider_affinity": "avito"}]},
)
assert r.status_code == 200, r.text
sql_text = str(db.execute.call_args.args[0])
assert "disabled_reason IS NULL" in sql_text
assert "scrape_proxies.enabled" in sql_text # fallback — не трогает при ручном disable