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
360 lines
16 KiB
Python
360 lines
16 KiB
Python
"""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()
|