gendesign/tradein-mvp/backend/tests/test_scrape_run_alert.py
bot-backend 80a063b482
Some checks failed
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Failing after 24s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
feat(observability): sentry alert on N consecutive scraper failures (Refs #827) (#833)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-30 20:10:29 +00:00

203 lines
8.5 KiB
Python

"""Unit tests for consecutive-failure Sentry alert in scrape_runs.
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_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,
_alert_if_consecutive_failures,
mark_banned,
mark_failed,
)
N = CONSECUTIVE_FAILURE_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()