gendesign/tradein-mvp/backend/tests/test_scraper_settings.py
lekss361 bfe56e44d6 feat(tradein): global scraper delay setting (applies across all scrapers)
- 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
2026-05-23 18:25:40 +03:00

196 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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