gendesign/tradein-mvp/backend/tests/test_scrape_run_alert.py
bot-backend 396d8a74b6
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m56s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m49s
fix(tradein/scraper): сторож «ноль результатов» перестаёт читать «не измерено» как ноль (#2703) (#2713)
2026-08-06 08:28:59 +00:00

360 lines
16 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.

"""Unit tests for consecutive-failure / consecutive-zero-result Sentry alerts in
scrape_runs (#2625: business-result alert extension).
All tests use a fully mocked DB session — no live DB required.
The mock simulates db.execute(...).fetchall() and db.execute(...).fetchone()
to control which run statuses are returned for the query in
_alert_if_consecutive_failures / _alert_if_consecutive_zero_results / _alert_on_run_id.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from app.services.scrape_runs import (
CONSECUTIVE_FAILURE_ALERT_THRESHOLD,
CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD,
_alert_if_consecutive_failures,
_alert_if_consecutive_zero_results,
mark_banned,
mark_done,
mark_failed,
)
N = CONSECUTIVE_FAILURE_ALERT_THRESHOLD # 3
NZ = CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD # 3
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_rows(*statuses: str) -> list[SimpleNamespace]:
"""Build a list of fake row objects with .status attribute."""
return [SimpleNamespace(status=s) for s in statuses]
def _make_db(streak_rows: list[SimpleNamespace]) -> MagicMock:
"""Return a mock Session whose execute().fetchall() returns streak_rows."""
execute_result = MagicMock()
execute_result.fetchall.return_value = streak_rows
db = MagicMock()
db.execute.return_value = execute_result
return db
# ---------------------------------------------------------------------------
# _alert_if_consecutive_failures — unit tests
# ---------------------------------------------------------------------------
class TestAlertIfConsecutiveFailures:
def test_no_alert_when_fewer_than_n_runs(self) -> None:
"""< N finished runs → no alert regardless of status."""
db = _make_db(_make_rows(*["failed"] * (N - 1)))
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_failures(db, "avito")
mock_sentry.capture_message.assert_not_called()
def test_no_alert_when_streak_is_n_minus_1(self) -> None:
"""N-1 consecutive failures (plus one older success) → no alert."""
# Rows ordered newest-first: N-1 failures, then one 'done'
rows = _make_rows(*["failed"] * (N - 1), "done")
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_failures(db, "avito")
mock_sentry.capture_message.assert_not_called()
def test_alert_fires_exactly_at_n(self) -> None:
"""Exactly N consecutive failures (no older run) → alert sent once."""
rows = _make_rows(*["failed"] * N)
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_failures(db, "avito")
mock_sentry.capture_message.assert_called_once()
(msg,) = mock_sentry.capture_message.call_args.args
assert "avito" in msg
assert str(N) in msg
def test_alert_fires_with_mixed_failed_banned(self) -> None:
"""Mix of 'failed' and 'banned' within N streak → alert sent."""
rows = _make_rows("banned", "failed", "banned")
assert len(rows) == N
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_failures(db, "cian")
mock_sentry.capture_message.assert_called_once()
def test_alert_fires_when_n_plus_1_is_done(self) -> None:
"""N failures newest + 1 older 'done' → alert (streak just reached N)."""
rows = _make_rows(*["failed"] * N, "done")
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_failures(db, "yandex")
mock_sentry.capture_message.assert_called_once()
def test_no_alert_when_n_plus_1_is_also_failed(self) -> None:
"""N failures newest + 1 older 'failed' → no alert (streak > N, already fired)."""
rows = _make_rows(*["failed"] * (N + 1))
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_failures(db, "avito")
mock_sentry.capture_message.assert_not_called()
def test_no_alert_when_first_n_has_success(self) -> None:
"""If any of the N newest runs is 'done' → no alert."""
# 2 failures then 1 success in newest N
rows = _make_rows("failed", "done", "failed")
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_failures(db, "avito")
mock_sentry.capture_message.assert_not_called()
def test_sentry_exception_does_not_propagate(self) -> None:
"""If sentry_sdk.capture_message raises, the function must NOT raise."""
rows = _make_rows(*["failed"] * N)
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mock_sentry.capture_message.side_effect = RuntimeError("sentry down")
# Should not raise
_alert_if_consecutive_failures(db, "avito")
def test_db_query_exception_does_not_propagate(self) -> None:
"""If db.execute raises, the function must NOT raise."""
db = MagicMock()
db.execute.side_effect = Exception("connection lost")
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_failures(db, "avito")
mock_sentry.capture_message.assert_not_called()
# ---------------------------------------------------------------------------
# mark_failed / mark_banned integration — verify alert hook is called
# ---------------------------------------------------------------------------
def _make_db_for_mark(source: str, streak_rows: list[SimpleNamespace]) -> MagicMock:
"""Mock DB for mark_failed/mark_banned.
Sequence of execute() calls in mark_failed:
1. UPDATE scrape_runs SET status='failed' ... RETURNING id → .first()
2. (after rollback) SELECT source FROM scrape_runs WHERE id=... → .fetchone()
3. SELECT status FROM scrape_runs WHERE source=... LIMIT N+1 → .fetchall()
"""
db = MagicMock()
# Call 1 (mark_failed UPDATE): .first() returns a row with id
update_result = MagicMock()
update_result.first.return_value = SimpleNamespace(id=42)
# Call 2 (_alert_on_run_id SELECT source): .fetchone() returns source row
source_result = MagicMock()
source_result.fetchone.return_value = SimpleNamespace(source=source)
# Call 3 (_alert_if_consecutive_failures SELECT statuses): .fetchall()
streak_result = MagicMock()
streak_result.fetchall.return_value = streak_rows
db.execute.side_effect = [update_result, source_result, streak_result]
return db
class TestMarkFailedAlertIntegration:
def test_mark_failed_triggers_alert_on_nth_failure(self) -> None:
"""mark_failed integrates with alert helper: alert fires on Nth streak."""
rows = _make_rows(*["failed"] * N)
db = _make_db_for_mark("avito", rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mark_failed(db, 42, "timeout", {})
mock_sentry.capture_message.assert_called_once()
def test_mark_failed_no_alert_below_threshold(self) -> None:
"""mark_failed: no alert when streak < N."""
rows = _make_rows(*["failed"] * (N - 1))
db = _make_db_for_mark("avito", rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mark_failed(db, 42, "timeout", {})
mock_sentry.capture_message.assert_not_called()
class TestMarkBannedAlertIntegration:
def test_mark_banned_triggers_alert_on_nth_failure(self) -> None:
"""mark_banned integrates with alert helper: alert fires on Nth streak."""
rows = _make_rows(*["banned"] * N)
# Reuse the same mock builder — mark_banned has same execute sequence
db = _make_db_for_mark("cian", rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mark_banned(db, 42, "403 Forbidden", {})
mock_sentry.capture_message.assert_called_once()
(msg,) = mock_sentry.capture_message.call_args.args
assert "cian" in msg
def test_mark_banned_no_alert_below_threshold(self) -> None:
"""mark_banned: no alert when streak < N."""
rows = _make_rows(*["banned"] * (N - 1))
db = _make_db_for_mark("cian", rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mark_banned(db, 42, "403 Forbidden", {})
mock_sentry.capture_message.assert_not_called()
# ---------------------------------------------------------------------------
# #2625: _alert_if_consecutive_zero_results — 'done' с lots_fetched=0, N подряд
# ---------------------------------------------------------------------------
# #2703: сторож читает counters, а НЕ колонку total_seen — у той DEFAULT 0, по ней
# «прогон не сообщил результата» неотличимо от «сообщил ноль». total_seen оставлен в
# фикстурах как исторический контекст: именно его читал прежний сторож.
def _zero_row() -> SimpleNamespace:
"""Успешно завершённый прогон ('done') с ИЗМЕРЕННЫМ нулём лотов — капча/пустая
выдача-под-видом-успеха (#2625)."""
return SimpleNamespace(status="done", counters={"lots_fetched": 0}, total_seen=0)
def _nonzero_row(total_seen: int = 50) -> SimpleNamespace:
"""Успешно завершённый прогон с реальным результатом — прерывает "нулевой" стрик."""
return SimpleNamespace(
status="done", counters={"lots_fetched": total_seen}, total_seen=total_seen
)
def _other_status_row(status: str) -> SimpleNamespace:
"""failed/banned/cancelled — НЕ 'done', прерывает "нулевой" стрик (уже покрыт
_alert_if_consecutive_failures отдельно)."""
return SimpleNamespace(status=status, counters={"lots_fetched": 0}, total_seen=0)
class TestAlertIfConsecutiveZeroResults:
def test_no_alert_when_fewer_than_n_runs(self) -> None:
"""(d) N-1 подряд done-с-нулём (< порога) → алерт НЕ шлётся."""
db = _make_db([_zero_row() for _ in range(NZ - 1)])
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_zero_results(db, "cian")
mock_sentry.capture_message.assert_not_called()
def test_alert_fires_exactly_at_n(self) -> None:
"""(d) ровно N done-с-нулём подряд → алерт шлётся ровно один раз."""
db = _make_db([_zero_row() for _ in range(NZ)])
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_zero_results(db, "cian")
mock_sentry.capture_message.assert_called_once()
(msg,) = mock_sentry.capture_message.call_args.args
assert "cian" in msg
assert str(NZ) in msg
def test_no_alert_when_n_minus_1_plus_nonzero_done(self) -> None:
"""N-1 done-с-нулём + один done с реальным результатом среди первых N → нет алерта."""
rows = [_zero_row() for _ in range(NZ - 1)] + [_nonzero_row()]
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_zero_results(db, "yandex")
mock_sentry.capture_message.assert_not_called()
def test_no_alert_when_streak_broken_by_failed_status(self) -> None:
"""'failed' с total_seen=0 в первых N НЕ считается done-с-нулём → нет алерта
(тот класс уже покрыт _alert_if_consecutive_failures)."""
rows = [_zero_row(), _other_status_row("failed"), _zero_row()]
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_zero_results(db, "cian")
mock_sentry.capture_message.assert_not_called()
def test_no_alert_when_n_plus_1_is_also_zero_done(self) -> None:
"""N нулей newest + ещё один нулевой старше → анти-спам, алерт уже был бы
отправлен раньше."""
db = _make_db([_zero_row() for _ in range(NZ + 1)])
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_zero_results(db, "cian")
mock_sentry.capture_message.assert_not_called()
def test_alert_fires_when_n_plus_1_is_nonzero(self) -> None:
"""N нулей newest + старше — реальный результат → стрик именно достиг N сейчас."""
rows = [_zero_row() for _ in range(NZ)] + [_nonzero_row()]
db = _make_db(rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_zero_results(db, "cian")
mock_sentry.capture_message.assert_called_once()
def test_sentry_exception_does_not_propagate(self) -> None:
db = _make_db([_zero_row() for _ in range(NZ)])
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mock_sentry.capture_message.side_effect = RuntimeError("sentry down")
_alert_if_consecutive_zero_results(db, "cian") # must not raise
def test_db_query_exception_does_not_propagate(self) -> None:
db = MagicMock()
db.execute.side_effect = Exception("connection lost")
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
_alert_if_consecutive_zero_results(db, "cian")
mock_sentry.capture_message.assert_not_called()
def _make_db_for_mark_done(source: str, streak_rows: list[SimpleNamespace]) -> MagicMock:
"""Mock DB for mark_done (#2625 zero-result alert hook).
Sequence of execute() calls in mark_done:
1. UPDATE scrape_runs SET status='done' ... RETURNING id → .first()
2. (_alert_on_run_id) SELECT source FROM scrape_runs WHERE id=... → .fetchone()
3. (_alert_if_consecutive_zero_results) SELECT status, counters ... → .fetchall()
"""
db = MagicMock()
update_result = MagicMock()
update_result.first.return_value = SimpleNamespace(id=42)
source_result = MagicMock()
source_result.fetchone.return_value = SimpleNamespace(source=source)
streak_result = MagicMock()
streak_result.fetchall.return_value = streak_rows
db.execute.side_effect = [update_result, source_result, streak_result]
return db
class TestMarkDoneZeroResultAlertIntegration:
"""(d) mark_done wires _alert_if_consecutive_zero_results — red/green on N vs N-1."""
def test_mark_done_triggers_alert_on_nth_zero_streak(self) -> None:
rows = [_zero_row() for _ in range(NZ)]
db = _make_db_for_mark_done("cian", rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mark_done(db, 42, {"lots_fetched": 0, "lots_inserted": 0})
mock_sentry.capture_message.assert_called_once()
(msg,) = mock_sentry.capture_message.call_args.args
assert "cian" in msg
def test_mark_done_no_alert_below_threshold(self) -> None:
rows = [_zero_row() for _ in range(NZ - 1)]
db = _make_db_for_mark_done("cian", rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mark_done(db, 42, {"lots_fetched": 0, "lots_inserted": 0})
mock_sentry.capture_message.assert_not_called()
def test_mark_done_with_nonzero_result_does_not_alert(self) -> None:
"""mark_done с реальным результатом обрывает стрик — свежая (newest) строка
не done-с-нулём → нет алерта, даже если предыдущие N-1 были нулевыми."""
rows = [_nonzero_row(120)] + [_zero_row() for _ in range(NZ - 1)]
db = _make_db_for_mark_done("cian", rows)
with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry:
mark_done(db, 42, {"lots_fetched": 120, "lots_inserted": 8})
mock_sentry.capture_message.assert_not_called()