"""test_server_pacing.py — юниты для per-provider pacing-интервалов + HTTP-эндпоинтов. Проверяет: 1. per-provider env override переопределяет глобал для конкретного провайдера; 2. отсутствие per-provider env → фолбэк на глобальный BROWSER_MIN_PAGE_INTERVAL_S; 3. невалидный per-provider env (не float) → фолбэк на глобал, без исключения; 4. пустая строка как per-provider env → фолбэк на глобал (ValueError при float("")). 5. GET /pacing — возвращает текущие live-значения + env-дефолты для всех провайдеров; 6. PUT /pacing — обновляет _MIN_PAGE_INTERVAL_BY_PROVIDER, валидирует source/interval_s. camoufox/Playwright НЕ поднимается: тесты работают с чистым dict'ом и aiohttp mock. Запуск (из tradein-mvp/browser/):: python -m pytest test_server_pacing.py -q """ from __future__ import annotations import asyncio import importlib.util import json from pathlib import Path from typing import Any import pytest from aiohttp.test_utils import make_mocked_request # server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути. _SERVER_PATH = Path(__file__).resolve().parent / "server.py" _spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH) assert _spec is not None and _spec.loader is not None server = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(server) # ── _resolve_min_interval ──────────────────────────────────────────────────────── def test_per_provider_override_replaces_global() -> None: """BROWSER_MIN_PAGE_INTERVAL_S_CIAN=18 + глобал=12 → cian=18, avito=12.""" fake_env = { "BROWSER_MIN_PAGE_INTERVAL_S": "12", "BROWSER_MIN_PAGE_INTERVAL_S_CIAN": "18", } # Патчим глобал в модуле, чтобы фолбэк брался из него (не из os.environ при load). original_global = server.BROWSER_MIN_PAGE_INTERVAL_S server.BROWSER_MIN_PAGE_INTERVAL_S = 12.0 try: assert server._resolve_min_interval("cian", fake_env) == 18.0 assert server._resolve_min_interval("avito", fake_env) == 12.0 finally: server.BROWSER_MIN_PAGE_INTERVAL_S = original_global def test_no_per_provider_env_falls_back_to_global() -> None: """Нет BROWSER_MIN_PAGE_INTERVAL_S_YANDEX → фолбэк на глобал.""" fake_env: dict[str, str] = {} original_global = server.BROWSER_MIN_PAGE_INTERVAL_S server.BROWSER_MIN_PAGE_INTERVAL_S = 5.0 try: result = server._resolve_min_interval("yandex", fake_env) assert result == 5.0 finally: server.BROWSER_MIN_PAGE_INTERVAL_S = original_global def test_invalid_per_provider_env_falls_back_to_global() -> None: """Невалидный env (не float) → фолбэк на глобал, без исключения.""" fake_env = {"BROWSER_MIN_PAGE_INTERVAL_S_AVITO": "abc"} original_global = server.BROWSER_MIN_PAGE_INTERVAL_S server.BROWSER_MIN_PAGE_INTERVAL_S = 7.0 try: result = server._resolve_min_interval("avito", fake_env) assert result == 7.0 finally: server.BROWSER_MIN_PAGE_INTERVAL_S = original_global def test_empty_string_per_provider_env_falls_back_to_global() -> None: """Пустая строка env → float("") → ValueError → фолбэк на глобал.""" fake_env = {"BROWSER_MIN_PAGE_INTERVAL_S_GENERIC": ""} original_global = server.BROWSER_MIN_PAGE_INTERVAL_S server.BROWSER_MIN_PAGE_INTERVAL_S = 3.0 try: result = server._resolve_min_interval("generic", fake_env) assert result == 3.0 finally: server.BROWSER_MIN_PAGE_INTERVAL_S = original_global def test_zero_per_provider_env_disables_pacing() -> None: """BROWSER_MIN_PAGE_INTERVAL_S_AVITO=0 → 0.0 (без пейсинга для avito).""" fake_env = { "BROWSER_MIN_PAGE_INTERVAL_S": "12", "BROWSER_MIN_PAGE_INTERVAL_S_AVITO": "0", } original_global = server.BROWSER_MIN_PAGE_INTERVAL_S server.BROWSER_MIN_PAGE_INTERVAL_S = 12.0 try: assert server._resolve_min_interval("avito", fake_env) == 0.0 # Другие провайдеры без override получают глобал. assert server._resolve_min_interval("cian", fake_env) == 12.0 finally: server.BROWSER_MIN_PAGE_INTERVAL_S = original_global def test_all_providers_covered() -> None: """Все 4 провайдера (avito/cian/yandex/generic) обрабатываются резолвером.""" for provider in ("avito", "cian", "yandex", "generic"): result = server._resolve_min_interval(provider, {}) assert isinstance(result, float), f"{provider}: ожидали float, получили {type(result)}" # ── GET /pacing ───────────────────────────────────────────────────────────────── def _json_body(response: Any) -> dict[str, Any]: return json.loads(response.body.decode()) @pytest.fixture(autouse=True) def _reset_pacing_state(monkeypatch: pytest.MonkeyPatch) -> None: """Восстанавливаем _MIN_PAGE_INTERVAL_BY_PROVIDER после каждого теста.""" original = dict(server._MIN_PAGE_INTERVAL_BY_PROVIDER) yield server._MIN_PAGE_INTERVAL_BY_PROVIDER.clear() server._MIN_PAGE_INTERVAL_BY_PROVIDER.update(original) def test_pacing_get_returns_all_providers() -> None: """GET /pacing возвращает providers для всех 4 источников.""" response = asyncio.run(server.pacing_get_handler(make_mocked_request("GET", "/pacing"))) body = _json_body(response) assert response.status == 200 providers = {p["source"]: p for p in body["providers"]} assert set(providers) == {"avito", "cian", "yandex", "generic"} def test_pacing_get_reflects_live_value(monkeypatch: pytest.MonkeyPatch) -> None: """GET /pacing.interval_s отражает живое значение из _MIN_PAGE_INTERVAL_BY_PROVIDER.""" server._MIN_PAGE_INTERVAL_BY_PROVIDER["cian"] = 99.0 response = asyncio.run(server.pacing_get_handler(make_mocked_request("GET", "/pacing"))) body = _json_body(response) providers = {p["source"]: p for p in body["providers"]} assert providers["cian"]["interval_s"] == 99.0 def test_pacing_get_has_env_default() -> None: """GET /pacing.env_default_s — это _resolve_min_interval(provider) из os.environ.""" response = asyncio.run(server.pacing_get_handler(make_mocked_request("GET", "/pacing"))) body = _json_body(response) for p in body["providers"]: assert isinstance(p["env_default_s"], float), ( f"{p['source']}: env_default_s должен быть float" ) # ── PUT /pacing ───────────────────────────────────────────────────────────────── def _put_request(body_dict: dict) -> Any: req = make_mocked_request("PUT", "/pacing") req.json = lambda: _coro_value(body_dict) # type: ignore[method-assign] return req def _coro_value(value: Any) -> Any: async def _inner() -> Any: return value return _inner() def test_pacing_put_updates_dict() -> None: """PUT /pacing с валидным source+interval_s обновляет _MIN_PAGE_INTERVAL_BY_PROVIDER.""" original = server._MIN_PAGE_INTERVAL_BY_PROVIDER.get("avito") asyncio.run(server.pacing_put_handler(_put_request({"source": "avito", "interval_s": 42.0}))) assert server._MIN_PAGE_INTERVAL_BY_PROVIDER["avito"] == 42.0 # Восстановить (на случай если фикстура autouse пропустит) if original is not None: server._MIN_PAGE_INTERVAL_BY_PROVIDER["avito"] = original def test_pacing_put_returns_ok() -> None: """PUT /pacing возвращает {ok:true, source, interval_s}.""" response = asyncio.run( server.pacing_put_handler(_put_request({"source": "yandex", "interval_s": 15.5})) ) assert response.status == 200 body = _json_body(response) assert body["ok"] is True assert body["source"] == "yandex" assert body["interval_s"] == 15.5 def test_pacing_put_zero_disables_pacing() -> None: """PUT /pacing с interval_s=0 разрешён (выключение пейсинга).""" response = asyncio.run( server.pacing_put_handler(_put_request({"source": "cian", "interval_s": 0})) ) assert response.status == 200 assert server._MIN_PAGE_INTERVAL_BY_PROVIDER["cian"] == 0.0 def test_pacing_put_bad_source_400() -> None: """PUT /pacing с невалидным source → 400.""" response = asyncio.run( server.pacing_put_handler(_put_request({"source": "domclick", "interval_s": 5.0})) ) assert response.status == 400 body = _json_body(response) assert "source" in body["error"] def test_pacing_put_negative_interval_422() -> None: """PUT /pacing с interval_s < 0 → 422.""" response = asyncio.run( server.pacing_put_handler(_put_request({"source": "avito", "interval_s": -1.0})) ) assert response.status == 422 def test_pacing_put_missing_interval_422() -> None: """PUT /pacing без interval_s → 422 (не число).""" response = asyncio.run( server.pacing_put_handler(_put_request({"source": "avito", "interval_s": "fast"})) ) assert response.status == 422 def test_pacing_put_invalid_json_400() -> None: """PUT /pacing с битым JSON → 400.""" req = make_mocked_request("PUT", "/pacing") async def _boom() -> Any: raise ValueError("not json") req.json = _boom # type: ignore[method-assign] response = asyncio.run(server.pacing_put_handler(req)) assert response.status == 400