Выбор оператора мобильного прокси опирался на две ненадёжные опоры. Первая: `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
382 lines
16 KiB
Python
382 lines
16 KiB
Python
"""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
|