All checks were successful
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
N1.ru перестал быть самостоятельным источником (переехал в Cian, подтверждено владельцем 2026-06). Активный скрап прекращён; 389 исторических listings в БД сохраняются без изменений. Убрано: - app/services/scrapers/n1.py (удалён) - run_n1_city_sweep + N1CitySweepCounters + N1_SWEEP_MAX_CONSECUTIVE_FAILURES из scrape_pipeline.py - trigger_n1_city_sweep_run + dispatch elif в scheduler.py - POST /scrape/n1 + GET /scrape/n1/runs endpoints из admin.py - N1Scraper из scrape_around dispatch dict и ScrapeRequest.multi_room_n1 - "n1": 5.0 из scraper_settings._DEFAULT_DELAY_BY_SOURCE - tests/test_n1_city_sweep.py, tests/test_n1_floor_parse.py (удалены) Добавлено: - data/sql/114_disable_n1_sweep.sql: UPDATE scrape_schedules SET enabled=false WHERE source='n1_city_sweep' (idempotent, строка сохраняется для audit-истории) Сохранено (историческое): - schemas/search.py Literal[..."n1"] — API-фильтр исторических listings - config.py scrape_allowed_hosts n1.ru / ekaterinburg.n1.ru - exporters/trade_in_pdf.py — цвет/лейбл N1 для отображения старых записей - geocoder.py и admin.py docstring N1-комментарии где не активный код 1802 passed, 2 deselected (test_search_cache_hit, test_cian_valuation.cache_hit)
315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""Tests for scraper_settings loader (cache + DB fallback + alias mapping + global delay).
|
||
|
||
Covers:
|
||
- Per-source value lookup + caching (main's tests)
|
||
- Global delay max() logic (our additions)
|
||
- Admin endpoint smoke
|
||
"""
|
||
|
||
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 import scraper_settings as ss
|
||
from app.services.scraper_settings import get_scraper_delay, invalidate_cache
|
||
|
||
# Patch target: _open_session is a module-level wrapper so tests can mock DB
|
||
_PATCH_TARGET = "app.services.scraper_settings._open_session"
|
||
|
||
|
||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_cache():
|
||
ss._CACHE.clear()
|
||
yield
|
||
ss._CACHE.clear()
|
||
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _mock_session_uniform(row_value: float | None) -> tuple[MagicMock, MagicMock]:
|
||
"""Mock that returns the same value for any key (context-manager style).
|
||
|
||
Used by tests that don't care about multi-key distinction.
|
||
Global key will also return the same value, so max() returns row_value.
|
||
"""
|
||
session = MagicMock()
|
||
session.__enter__ = MagicMock(return_value=session)
|
||
session.__exit__ = MagicMock(return_value=False)
|
||
if row_value is None:
|
||
session.execute.return_value.first.return_value = None
|
||
else:
|
||
session.execute.return_value.first.return_value = (row_value,)
|
||
mock_open = MagicMock(return_value=session)
|
||
return mock_open, session
|
||
|
||
|
||
def _make_mock_session(values: dict[str, float | None]) -> MagicMock:
|
||
"""Mock сессии БД с фиксированными значениями per source key.
|
||
|
||
Поддерживает разные значения для разных ключей — используется в тестах
|
||
global delay логики.
|
||
"""
|
||
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
|
||
|
||
|
||
# ── Main's tests: core cache + alias + fallback behaviour ─────────────────────
|
||
|
||
|
||
def test_returns_db_value_for_yandex():
|
||
"""DB value 7.5, global also 7.5 → max(7.5, 7.5) = 7.5."""
|
||
mock_open, _ = _mock_session_uniform(7.5)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
assert ss.get_scraper_delay("yandex") == 7.5
|
||
|
||
|
||
def test_yandex_subkeys_map_to_yandex_umbrella():
|
||
"""yandex_detail / yandex_newbuilding / yandex_valuation all read 'yandex' row."""
|
||
mock_open, _session = _mock_session_uniform(8.0)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
assert ss.get_scraper_delay("yandex_detail") == 8.0
|
||
assert ss.get_scraper_delay("yandex_newbuilding") == 8.0
|
||
assert ss.get_scraper_delay("yandex_valuation") == 8.0
|
||
# First call fetches yandex + global (2 DB calls). Remaining 2 subkey calls
|
||
# map to "yandex" (already cached) and "global" (already cached) → 0 more.
|
||
assert mock_open.call_count == 2
|
||
|
||
|
||
def test_cache_hit_within_ttl():
|
||
"""After first call both yandex+global are cached; repeats hit cache only."""
|
||
mock_open, _session = _mock_session_uniform(6.0)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
ss.get_scraper_delay("yandex")
|
||
ss.get_scraper_delay("yandex")
|
||
ss.get_scraper_delay("yandex")
|
||
# First call: 2 DB hits (yandex + global). Subsequent: 0.
|
||
assert mock_open.call_count == 2
|
||
|
||
|
||
def test_cache_expires_after_ttl():
|
||
"""Force-age yandex key — next call re-fetches yandex only (global still fresh)."""
|
||
mock_open, _session = _mock_session_uniform(6.0)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
ss.get_scraper_delay("yandex") # +2 calls (yandex + global)
|
||
# Force-age the yandex entry
|
||
with ss._CACHE_LOCK:
|
||
v, t = ss._CACHE["yandex"]
|
||
ss._CACHE["yandex"] = (v, t - 999)
|
||
ss.get_scraper_delay("yandex") # +1 call (yandex stale; global still fresh)
|
||
assert mock_open.call_count == 3
|
||
|
||
|
||
def test_db_missing_row_returns_default():
|
||
"""Missing yandex row → _DEFAULT_DELAY_BY_SOURCE. Global None → 0.0. max = default."""
|
||
mock_open, _ = _mock_session_uniform(None)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
assert ss.get_scraper_delay("yandex") == ss._DEFAULT_DELAY_BY_SOURCE["yandex"]
|
||
|
||
|
||
def test_db_error_returns_default():
|
||
"""DB error → hardcoded default. Global error → 0.0. max = default."""
|
||
bad_session = MagicMock()
|
||
bad_session.__enter__ = MagicMock(return_value=bad_session)
|
||
bad_session.__exit__ = MagicMock(return_value=False)
|
||
bad_session.execute.side_effect = RuntimeError("db down")
|
||
bad_open = MagicMock(return_value=bad_session)
|
||
with patch(_PATCH_TARGET, bad_open):
|
||
assert ss.get_scraper_delay("yandex") == ss._DEFAULT_DELAY_BY_SOURCE["yandex"]
|
||
|
||
|
||
def test_unknown_source_uses_global_default():
|
||
"""Unknown source, DB row None → _GLOBAL_DEFAULT_DELAY. Global None → 0.0. max = 5.0."""
|
||
mock_open, _ = _mock_session_uniform(None)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
assert ss.get_scraper_delay("some_new_source") == ss._GLOBAL_DEFAULT_DELAY
|
||
|
||
|
||
def test_invalidate_cache_specific_source():
|
||
"""invalidate_cache(yandex) drops yandex entry; next call re-fetches yandex only."""
|
||
mock_open, _ = _mock_session_uniform(6.0)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
ss.get_scraper_delay("yandex") # +2 (yandex + global)
|
||
ss.invalidate_cache("yandex") # clears yandex key
|
||
ss.get_scraper_delay("yandex") # +1 (yandex stale; global still cached)
|
||
assert mock_open.call_count == 3
|
||
|
||
|
||
def test_invalidate_cache_all():
|
||
"""invalidate_cache() clears all entries; next call re-fetches both."""
|
||
mock_open, _ = _mock_session_uniform(6.0)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
ss.get_scraper_delay("yandex") # +2 (yandex + global)
|
||
ss.invalidate_cache() # clears everything
|
||
ss.get_scraper_delay("yandex") # +2 (both stale)
|
||
assert mock_open.call_count == 4
|
||
|
||
|
||
def test_invalidate_alias_invalidates_umbrella():
|
||
"""invalidate_cache('yandex_detail') clears the 'yandex' umbrella key."""
|
||
mock_open, _ = _mock_session_uniform(6.0)
|
||
with patch(_PATCH_TARGET, mock_open):
|
||
ss.get_scraper_delay("yandex_detail") # +2 (yandex + global)
|
||
ss.invalidate_cache("yandex_detail") # clears yandex (via alias)
|
||
ss.get_scraper_delay("yandex_detail") # +1 (yandex stale; global fresh)
|
||
assert mock_open.call_count == 3
|
||
|
||
|
||
# ── Our additions: global delay max() logic ───────────────────────────────────
|
||
|
||
|
||
def test_global_delay_takes_precedence_when_higher(monkeypatch):
|
||
"""global=10 > avito=7 → returns 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 → returns 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 → only per-source value applies."""
|
||
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):
|
||
"""Missing source row → _DEFAULT_DELAY_BY_SOURCE. global=0 → no floor."""
|
||
invalidate_cache()
|
||
|
||
mock_db = _make_mock_session({"domklik": None, "global": 0.0})
|
||
monkeypatch.setattr(ss_mod, "_open_session", lambda: mock_db)
|
||
|
||
delay = get_scraper_delay("domklik")
|
||
assert delay == ss_mod._DEFAULT_DELAY_BY_SOURCE["domklik"]
|
||
|
||
|
||
def test_global_missing_row_treated_as_zero(monkeypatch):
|
||
"""Missing 'global' row → fallback 0.0, avito=7.0 → max(7.0, 0.0) = 7.0."""
|
||
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
|
||
|
||
|
||
def test_db_error_falls_back_to_default_global_logic(monkeypatch):
|
||
"""DB error → graceful fallback, no crash. avito default=7.0, global error→0.0."""
|
||
invalidate_cache()
|
||
|
||
def broken_open():
|
||
raise RuntimeError("DB is down")
|
||
|
||
monkeypatch.setattr(ss_mod, "_open_session", broken_open)
|
||
|
||
delay = get_scraper_delay("avito")
|
||
assert delay == ss_mod._DEFAULT_DELAY_BY_SOURCE["avito"]
|
||
|
||
|
||
def test_invalidate_cache_single_source_global_logic(monkeypatch):
|
||
"""invalidate_cache(source) removes only that source from cache."""
|
||
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)
|
||
|
||
get_scraper_delay("cian")
|
||
get_scraper_delay("avito")
|
||
|
||
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_global_logic(monkeypatch):
|
||
"""invalidate_cache() without args clears the entire 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
|
||
|
||
|
||
# ── Admin API endpoint smoke ───────────────────────────────────────────────────
|
||
|
||
|
||
def test_list_scraper_settings_endpoint(monkeypatch):
|
||
"""GET /scraper-settings mounts and returns a list including 'global'."""
|
||
import sys
|
||
|
||
# Stub weasyprint (not on CI)
|
||
_wp = MagicMock()
|
||
sys.modules.setdefault("weasyprint", _wp)
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import 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")
|
||
|
||
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 = MagicMock()
|
||
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
|