"""Offline-тесты admin proxy-pool ручек (#2161). Покрытие (db мокается, NO live network/DB): - POST /api/v1/admin/proxies/bulk — UPSERT-счётчики, валидация affinity/kind - GET /api/v1/admin/proxies — маскировка пароля, фильтры - PATCH /api/v1/admin/proxies/{id} — enable/disable, 404 """ 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 # ── _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, "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.return_value = 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_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.return_value = 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