gendesign/tradein-mvp/backend/tests/test_alerts_become_events.py
bot-backend cdf493f345
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
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

405 lines
19 KiB
Python
Raw Permalink 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.

"""Сигналы о сбоях действительно становятся событиями 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))