- SQL migrations 053 (scraper_settings table) + 054 (seed global + per-source rows)
- scraper_settings.py: get_scraper_delay() returns max(per_source, global);
in-memory cache TTL=60s; invalidate_cache() for immediate effect on PUT
- avito/cian/n1 scrapers load delay from DB in __init__ (mirrors yandex pattern)
- Admin API: GET /scraper-settings (list all), PUT /scraper-settings/{source}
with cache invalidation on update; CAST(:d AS numeric) per psycopg v3 rules
- 10 unit tests: global>per_source, per_source>global, global=0, DB error fallback,
cache invalidation, API endpoint smoke
196 lines
6.7 KiB
Python
196 lines
6.7 KiB
Python
"""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
|