"""Offline tests for scraper_settings service (no DB required). Тестируем логику max(per_source, global) через монкей-патч _get_setting_cached. """ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from unittest.mock import MagicMock, patch import pytest import app.services.scraper_settings as ss_mod from app.services.scraper_settings import get_scraper_delay, invalidate_cache # ── helpers ────────────────────────────────────────────────────────────────── def _make_mock_session(values: dict[str, float | None]) -> MagicMock: """Создать mock сессии БД с фиксированными значениями per source key.""" mock_db = MagicMock() def fake_execute(sql, params): key = params["s"] result = MagicMock() val = values.get(key) result.first.return_value = (val,) if val is not None else None return result mock_db.execute = fake_execute mock_db.__enter__ = MagicMock(return_value=mock_db) mock_db.__exit__ = MagicMock(return_value=False) return mock_db # ── tests ───────────────────────────────────────────────────────────────────── def test_global_delay_takes_precedence_when_higher(monkeypatch): """global=10 > avito=7 → возвращает 10.""" invalidate_cache() mock_db = _make_mock_session({"avito": 7.0, "global": 10.0}) monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db) delay = get_scraper_delay("avito") assert delay == 10.0 def test_per_source_takes_precedence_when_higher(monkeypatch): """avito=7 > global=2 → возвращает 7.""" invalidate_cache() mock_db = _make_mock_session({"avito": 7.0, "global": 2.0}) monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db) delay = get_scraper_delay("avito") assert delay == 7.0 def test_global_zero_means_no_floor(monkeypatch): """global=0 → используется только per-source значение.""" invalidate_cache() mock_db = _make_mock_session({"cian": 5.0, "global": 0.0}) monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db) delay = get_scraper_delay("cian") assert delay == 5.0 def test_missing_per_source_row_uses_class_default(monkeypatch): """Если строки source в БД нет — фолбек на _DEFAULT_DELAY_BY_SOURCE.""" invalidate_cache() # n1 строки нет в БД, global=0 mock_db = _make_mock_session({"n1": None, "global": 0.0}) monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db) delay = get_scraper_delay("n1") # Дефолт для n1 = 5.0 assert delay == ss_mod._DEFAULT_DELAY_BY_SOURCE["n1"] def test_unknown_source_uses_global_default(monkeypatch): """Неизвестный source без строки в БД — _GLOBAL_DEFAULT_DELAY.""" invalidate_cache() mock_db = _make_mock_session({"some_new_source": None, "global": 0.0}) monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db) delay = get_scraper_delay("some_new_source") assert delay == ss_mod._GLOBAL_DEFAULT_DELAY def test_db_error_falls_back_to_default(monkeypatch): """Ошибка БД → graceful fallback на дефолт, не crash.""" invalidate_cache() def broken_open(): raise RuntimeError("DB is down") monkeypatch.setattr(ss_mod, "_open_session", broken_open) # Не должно кидать исключение delay = get_scraper_delay("avito") # При ошибке: per_source → _DEFAULT_DELAY_BY_SOURCE['avito'] = 7.0, global → 0.0 assert delay == 7.0 def test_invalidate_cache_single_source(monkeypatch): """invalidate_cache(source) удаляет только указанный source.""" invalidate_cache() mock_db = _make_mock_session({"cian": 5.0, "avito": 7.0, "global": 0.0}) monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db) # Прогреть кеш для cian и avito get_scraper_delay("cian") get_scraper_delay("avito") # Сбросить только cian invalidate_cache("cian") with ss_mod._CACHE_LOCK: assert "cian" not in ss_mod._CACHE assert "avito" in ss_mod._CACHE def test_invalidate_cache_all(monkeypatch): """invalidate_cache() без аргумента очищает весь кеш.""" invalidate_cache() mock_db = _make_mock_session({"avito": 7.0, "global": 0.0}) monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db) get_scraper_delay("avito") invalidate_cache() with ss_mod._CACHE_LOCK: assert len(ss_mod._CACHE) == 0 def test_global_missing_row_treated_as_zero(monkeypatch): """Если строки 'global' нет в БД — фолбек 0.0 (не применяем floor).""" invalidate_cache() mock_db = _make_mock_session({"avito": 7.0, "global": None}) monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db) delay = get_scraper_delay("avito") assert delay == 7.0 # ── Admin API integration smoke ─────────────────────────────────────────────── def test_list_scraper_settings_endpoint(monkeypatch): """GET /scraper-settings — эндпоинт монтируется, возвращает список.""" import sys from unittest.mock import MagicMock as MM # Stub weasyprint (нет на CI) _wp = MM() sys.modules.setdefault("weasyprint", _wp) from fastapi.testclient import TestClient from app.api.v1 import admin as admin_module from app.core.db import get_db from fastapi import FastAPI app = FastAPI() app.include_router(admin_module.router, prefix="/api/v1/admin") fake_rows = [ {"source": "avito", "request_delay_sec": 7.0, "description": "Avito", "updated_at": None}, {"source": "global", "request_delay_sec": 0.0, "description": "Global", "updated_at": None}, ] mock_db = MM() mock_db.execute.return_value.mappings.return_value.all.return_value = fake_rows app.dependency_overrides[get_db] = lambda: mock_db client = TestClient(app) r = client.get("/api/v1/admin/scraper-settings") assert r.status_code == 200 data = r.json() assert "settings" in data sources = [s["source"] for s in data["settings"]] assert "global" in sources assert "avito" in sources