gendesign/tradein-mvp/backend/tests/test_scraper_settings.py
bot-backend c07b5d7c5d
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
chore(tradein): деактивировать N1.ru scraper — источник переехал в Cian
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)
2026-06-16 14:50:51 +03:00

315 lines
12 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.

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