"""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()