"""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: """Ответ на DELETE ... RETURNING source (proxy_pool.clear_source_bans, #2600 п.2). 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 delete_sql = str(db.execute.call_args_list[1].args[0]) assert "DELETE FROM scrape_proxy_source_bans" in delete_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 assert not any( "DELETE FROM scrape_proxy_source_bans" 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