All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
405 lines
19 KiB
Python
405 lines
19 KiB
Python
"""Сигналы о сбоях действительно становятся событиями GlitchTip (#2674).
|
||
|
||
Почему обычной проверки уровня записи мало. В контейнере скрапера GlitchTip поднят
|
||
как `LoggingIntegration(level=INFO, event_level=ERROR)` (app/scheduler_main.py) —
|
||
значит WARNING остаётся строкой в docker-логе (которая теряется на редеплое) и
|
||
событием НЕ становится. Прод-цена этого: монитор устаревания СберИндекса отработал
|
||
24 раза, 9 из них со staleness-вердиктом — событий ноль; куки Домклика протухли
|
||
2026-08-03 — событий ноль.
|
||
|
||
Поэтому здесь тесты проверяют ФАКТ СОБЫТИЯ, а не levelno: `glitchtip_events()`
|
||
поднимает настоящий sentry-клиент с той же интеграцией и тем же event_level, что в
|
||
проде, но с транспортом-списком. Если правку откатить (ERROR → WARNING), список
|
||
останется пустым и тест покраснеет.
|
||
|
||
Оговорка, которую тесты проверить не могут: у GlitchTip-проекта сейчас нет ни правил,
|
||
ни получателей (#2673) — события будут видны в интерфейсе, но никому не отправятся.
|
||
|
||
Без сети, без БД.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from collections.abc import Iterator
|
||
from contextlib import contextmanager
|
||
from datetime import UTC, date, datetime, timedelta
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import httpx
|
||
import pytest
|
||
import sentry_sdk
|
||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import domclick_session as domclick_session_svc
|
||
from app.services import rosreestr_poll, sber_index
|
||
from app.tasks import deals_freshness_monitor as deals_mon
|
||
from app.tasks import domclick_detail_backfill as dc_backfill
|
||
from app.tasks import sber_freshness_monitor as sber_mon
|
||
|
||
# ── харнесс: настоящий клиент GlitchTip с прод-настройками, транспорт — список ──
|
||
|
||
|
||
@contextmanager
|
||
def glitchtip_events() -> Iterator[list[dict[str, Any]]]:
|
||
"""Собрать события так, как их увидел бы GlitchTip из контейнера скрапера.
|
||
|
||
Интеграция и event_level — копия app/scheduler_main.py. Клиент ставится только
|
||
на время блока (isolation_scope), глобальное состояние не трогаем.
|
||
"""
|
||
events: list[dict[str, Any]] = []
|
||
|
||
def _collect(event: dict[str, Any], _hint: dict[str, Any]) -> None:
|
||
"""before_send: событие уже собрано — записываем и НЕ отправляем (None)."""
|
||
events.append(event)
|
||
return None
|
||
|
||
client = sentry_sdk.Client(
|
||
dsn="https://public@localhost/1",
|
||
before_send=_collect,
|
||
default_integrations=False,
|
||
integrations=[LoggingIntegration(level=logging.INFO, event_level=logging.ERROR)],
|
||
)
|
||
with sentry_sdk.isolation_scope() as scope:
|
||
scope.set_client(client)
|
||
try:
|
||
yield events
|
||
finally:
|
||
# Иначе на каждый тест остаётся фоновый поток транспорта.
|
||
client.close()
|
||
|
||
|
||
def event_texts(events: list[dict[str, Any]]) -> list[str]:
|
||
"""Тексты событий — и логовых (logentry), и явных capture_message (message)."""
|
||
texts: list[str] = []
|
||
for event in events:
|
||
entry = event.get("logentry")
|
||
if isinstance(entry, dict):
|
||
texts.append(str(entry.get("formatted") or entry.get("message") or ""))
|
||
elif isinstance(event.get("message"), str):
|
||
texts.append(event["message"])
|
||
return texts
|
||
|
||
|
||
def test_harness_itself_drops_warnings() -> None:
|
||
"""Мета-проверка харнесса: WARNING не событие, ERROR — событие.
|
||
|
||
Без этого зелёные тесты ниже ничего не доказывали бы (пустой список мог бы быть
|
||
следствием сломанного харнесса, а не сломанного алерта).
|
||
"""
|
||
log = logging.getLogger("test_alerts_become_events.meta")
|
||
with glitchtip_events() as events:
|
||
log.warning("тихо")
|
||
log.error("громко")
|
||
assert event_texts(events) == ["громко"]
|
||
|
||
|
||
# ── 1. СберИндекс: монитор устаревания ────────────────────────────────────────
|
||
|
||
|
||
class _FakeMonitorDB:
|
||
"""Session-мок мониторов свежести.
|
||
|
||
Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает
|
||
ещё время последнего ПОЛНОГО прогона загрузки и её такт — именно они, а не
|
||
календарный возраст периода, решают, быть ли тревоге.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
latest: date | None,
|
||
last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC),
|
||
interval_days: str = "7",
|
||
) -> None:
|
||
self._latest = latest
|
||
self._last_pull = last_pull
|
||
self._interval_days = interval_days
|
||
|
||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||
sql = str(stmt)
|
||
result = MagicMock()
|
||
if "scrape_runs" in sql:
|
||
result.first.return_value = MagicMock(last_pull=self._last_pull)
|
||
elif "scrape_schedules" in sql:
|
||
result.first.return_value = MagicMock(interval_days=self._interval_days)
|
||
else:
|
||
result.first.return_value = MagicMock(latest=self._latest)
|
||
return result
|
||
|
||
def rollback(self) -> None:
|
||
pass
|
||
|
||
|
||
def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None:
|
||
monkeypatch.setattr(module.runs_mod, "update_heartbeat", lambda *a, **k: None)
|
||
monkeypatch.setattr(module.runs_mod, "mark_done", lambda *a, **k: None)
|
||
monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None)
|
||
|
||
|
||
def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Загрузка встала (полный прогон 20 суток назад при такте 7) → событие.
|
||
|
||
20 суток — реальный разрыв прод-истории между полными прогонами 07-17 и 08-06.
|
||
"""
|
||
_patch_runs(monkeypatch, sber_mon)
|
||
db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC))
|
||
with glitchtip_events() as events:
|
||
out = sber_mon.check_sber_freshness(
|
||
db, # type: ignore[arg-type]
|
||
run_id=1,
|
||
params={},
|
||
now=datetime(2026, 8, 6, tzinfo=UTC),
|
||
)
|
||
assert out["alert"] == 1
|
||
assert any("sber freshness" in t for t in event_texts(events)), (
|
||
"отставание загрузки не стало событием — WARNING до GlitchTip не долетает"
|
||
)
|
||
|
||
|
||
def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет.
|
||
|
||
Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR:
|
||
возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость
|
||
от таких событий и делает настоящий отказ незаметным.
|
||
"""
|
||
_patch_runs(monkeypatch, sber_mon)
|
||
db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон)
|
||
with glitchtip_events() as events:
|
||
out = sber_mon.check_sber_freshness(
|
||
db, # type: ignore[arg-type]
|
||
run_id=2,
|
||
params={},
|
||
now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC),
|
||
)
|
||
assert out["alert"] == 0
|
||
assert event_texts(events) == []
|
||
|
||
|
||
def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Бенчмарк пуст — монитор не может работать вовсе; mark_failed виден только стрику."""
|
||
_patch_runs(monkeypatch, sber_mon)
|
||
db = _FakeMonitorDB(None)
|
||
with glitchtip_events() as events:
|
||
sber_mon.check_sber_freshness(
|
||
db, # type: ignore[arg-type]
|
||
run_id=3,
|
||
params={},
|
||
now=datetime(2026, 8, 6, tzinfo=UTC),
|
||
)
|
||
assert any("у оценщика нет серии" in t for t in event_texts(events))
|
||
|
||
|
||
def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Тот же класс у соседнего монитора сделок — найдено «шире» по #2674."""
|
||
_patch_runs(monkeypatch, deals_mon)
|
||
db = _FakeMonitorDB(None)
|
||
with glitchtip_events() as events:
|
||
deals_mon.check_deals_freshness(
|
||
db, # type: ignore[arg-type]
|
||
run_id=4,
|
||
params={},
|
||
now=datetime(2026, 8, 6, tzinfo=UTC),
|
||
)
|
||
assert any("deals пуста" in t for t in event_texts(events))
|
||
|
||
|
||
# ── 2. СберИндекс: 404 датасета (почему бенчмарк перестаёт обновляться) ────────
|
||
|
||
|
||
async def test_sber_index_404_becomes_event() -> None:
|
||
"""404 = переименованный slug: сбой ПЕРМАНЕНТНЫЙ, а был тише соседних 5xx-веток."""
|
||
request = httpx.Request("GET", "https://sberindex.ru/api/sowa")
|
||
|
||
def _raise_404(*args: Any, **kwargs: Any) -> Any:
|
||
raise httpx.HTTPStatusError(
|
||
"404", request=request, response=httpx.Response(404, request=request)
|
||
)
|
||
|
||
with (
|
||
patch.object(sber_index, "fetch_sber_index", _raise_404),
|
||
glitchtip_events() as events,
|
||
):
|
||
counters = await sber_index.pull_sber_indices(
|
||
MagicMock(),
|
||
cities={"66": "Свердловская область"},
|
||
dashboards=[sber_index.SBER_DASHBOARDS[0]],
|
||
)
|
||
|
||
assert counters["errors"] == 1
|
||
assert any("404 for dashboard" in t for t in event_texts(events))
|
||
|
||
|
||
# ── 3. Куки Домклика: по факту и ЗАРАНЕЕ (образец — #2658 для Циана) ──────────
|
||
|
||
|
||
class _FakeDomclickDB:
|
||
"""Session-мок: строка кук для session_expires_at + пустой снапшот листингов."""
|
||
|
||
def __init__(self, expires_at: datetime | None) -> None:
|
||
self._expires_at = expires_at
|
||
|
||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||
result = MagicMock()
|
||
if "domclick_session_cookies" in str(stmt):
|
||
result.first.return_value = None if self._expires_at is None else (self._expires_at,)
|
||
else:
|
||
# snapshot листингов — пусто, прогон завершится до BrowserFetcher
|
||
result.mappings.return_value.all.return_value = []
|
||
return result
|
||
|
||
|
||
async def _run_domclick(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
*,
|
||
cookies: dict[str, str] | None,
|
||
expires_at: datetime | None,
|
||
) -> list[str]:
|
||
_patch_runs(monkeypatch, dc_backfill)
|
||
monkeypatch.setattr(domclick_session_svc, "load_session", lambda _db: cookies)
|
||
db = _FakeDomclickDB(expires_at)
|
||
with glitchtip_events() as events:
|
||
await dc_backfill.run_domclick_detail_backfill(
|
||
db, # type: ignore[arg-type]
|
||
run_id=10,
|
||
params={},
|
||
)
|
||
return event_texts(events)
|
||
|
||
|
||
async def test_domclick_expired_cookies_become_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Прод 2026-08-03: протухли, единственным следом был WARNING в docker-логе."""
|
||
expired = datetime.now(tz=UTC) - timedelta(days=3)
|
||
texts = await _run_domclick(monkeypatch, cookies=None, expires_at=expired)
|
||
assert any("протухли" in t for t in texts), "протухшие куки не стали событием"
|
||
# Дата в тексте — чтобы оператор сразу видел, чинить сейчас или это давняя дыра.
|
||
assert any(expired.strftime("%Y-%m-%d") in t for t in texts)
|
||
|
||
|
||
async def test_domclick_missing_cookies_reason_differs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""«Кук нет вовсе» и «протухли» лечатся одинаково, но диагностируются по-разному."""
|
||
texts = await _run_domclick(monkeypatch, cookies=None, expires_at=None)
|
||
assert any("кук DomClick нет в БД" in t for t in texts)
|
||
|
||
|
||
async def test_domclick_warns_before_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Предупреждение ЗАРАНЕЕ: куки ещё рабочие, но жить им меньше порога.
|
||
|
||
Сигнал по факту протухания приходит, когда обогащение уже встало, а обновление
|
||
кук — ручная операция. По образцу #2658 (Циан).
|
||
"""
|
||
soon = datetime.now(tz=UTC) + timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS - 1)
|
||
texts = await _run_domclick(monkeypatch, cookies={"CAS_ID": "x"}, expires_at=soon)
|
||
assert any("протухнут" in t for t in texts), "не предупредили заранее"
|
||
|
||
|
||
async def test_domclick_fresh_cookies_are_silent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Свежие куки — ни одного события."""
|
||
far = datetime.now(tz=UTC) + timedelta(days=25)
|
||
texts = await _run_domclick(monkeypatch, cookies={"CAS_ID": "x"}, expires_at=far)
|
||
assert texts == []
|
||
|
||
|
||
def test_domclick_expiry_query_asks_for_the_used_row() -> None:
|
||
"""valid_only=True — срок ИМЕННО той записи, которую взял бы load_session.
|
||
|
||
При нескольких аккаунтах свежайшая-любая может быть чужой протухшей строкой.
|
||
"""
|
||
db = MagicMock()
|
||
db.execute.return_value.first.return_value = None
|
||
domclick_session_svc.session_expires_at(db, valid_only=True)
|
||
assert db.execute.call_args.args[1] == {"valid_only": True}
|
||
|
||
|
||
# ── 4. Поллер Росреестра: сбой каталога vs выход квартала ─────────────────────
|
||
|
||
|
||
def _client_returning(status_code: int) -> MagicMock:
|
||
client = MagicMock()
|
||
client.get = AsyncMock(return_value=httpx.Response(status_code, text=""))
|
||
return client
|
||
|
||
|
||
async def test_rosreestr_broken_index_becomes_event() -> None:
|
||
"""Каталог не отдаёт 200 — поллер слеп. Портал уже один раз переезжал."""
|
||
with glitchtip_events() as events:
|
||
available = await rosreestr_poll.check_new_quarter_available(
|
||
_client_returning(503), 2026, 3
|
||
)
|
||
assert available is False
|
||
assert any("unexpected HTTP 503" in t for t in event_texts(events))
|
||
|
||
|
||
async def test_rosreestr_stub_instead_of_zip_becomes_event() -> None:
|
||
"""Файл есть в листинге, но HEAD отдал заглушку — тот сбой, из-за которого уже врали.
|
||
|
||
Ровно поведение старой Bitrix-заглушки: HTTP 200 + text/html вместо архива.
|
||
"""
|
||
index_html = '<a href="3%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./">q</a>'
|
||
folder_html = '<a href="dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_3.csv.zip">f</a>'
|
||
client = MagicMock()
|
||
client.get = AsyncMock(
|
||
side_effect=[
|
||
httpx.Response(200, text=index_html),
|
||
httpx.Response(200, text=folder_html),
|
||
]
|
||
)
|
||
client.head = AsyncMock(
|
||
return_value=httpx.Response(
|
||
200, text="stub", headers={"content-type": "text/html", "content-length": "512"}
|
||
)
|
||
)
|
||
with glitchtip_events() as events:
|
||
available = await rosreestr_poll.check_new_quarter_available(client, 2026, 3)
|
||
|
||
assert available is False
|
||
assert any("soft-404 guard" in t for t in event_texts(events))
|
||
|
||
|
||
async def test_rosreestr_quarter_not_published_is_silent() -> None:
|
||
"""Каталог жив, папки квартала ещё нет — самый частый прогон, событий быть не должно."""
|
||
with glitchtip_events() as events:
|
||
available = await rosreestr_poll.check_new_quarter_available(
|
||
_client_returning(200), 2026, 3
|
||
)
|
||
assert available is False
|
||
assert event_texts(events) == []
|
||
|
||
|
||
async def test_rosreestr_timeout_stays_out_of_events() -> None:
|
||
"""Осознанно НЕ событие: транспортный блип раз в 28 дней сам пройдёт.
|
||
|
||
«Квартал так и не приехал» ловит deals_freshness_monitor по max(deal_date).
|
||
"""
|
||
client = MagicMock()
|
||
client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
|
||
with glitchtip_events() as events:
|
||
await rosreestr_poll.check_new_quarter_available(client, 2026, 3)
|
||
assert event_texts(events) == []
|
||
|
||
|
||
async def test_rosreestr_new_quarter_becomes_event() -> None:
|
||
"""Хорошая новость — тоже событие: она требует ручного импорта оператором.
|
||
|
||
Уровень info, а не error: событие в GlitchTip есть, а error-rate и стрик-алерты
|
||
не начинают врать про «сбой». INFO-строка в логе живёт до ближайшего редеплоя.
|
||
"""
|
||
with (
|
||
patch.object(rosreestr_poll, "latest_loaded_quarter", MagicMock(return_value=(2026, 2))),
|
||
patch.object(rosreestr_poll, "check_new_quarter_available", AsyncMock(return_value=True)),
|
||
glitchtip_events() as events,
|
||
):
|
||
out = await rosreestr_poll.poll_rosreestr_new_quarter(MagicMock())
|
||
|
||
assert out == {
|
||
"available": True,
|
||
"year": 2026,
|
||
"quarter": 3,
|
||
"latest_loaded_year": 2026,
|
||
"latest_loaded_quarter": 2,
|
||
}
|
||
assert any("доступен новый квартал Q3 2026" in t for t in event_texts(events))
|