"""Тесты breadcrumb-логирования etl_newbuilding_crossload task (issue #2445 D6). Раньше эта таска не имела ни try/except, ни breadcrumb-записи в отличие от всех соседних nightly/weekly-тасок в этой директории (objective_etl.py и т.д.) — сбой run_crossload() был виден только в Celery FAILURE + GlitchTip, но не в operational-логе (objective_scrape_runs), который проверяет оператор. Покрытие: - success-path: run_id создаётся, run_crossload() вызывается с db=, breadcrumb помечается 'done' со счётчиками source_rows/upserted/skipped. - disabled-путь (TRADEIN_DATABASE_URL пуст): breadcrumb 'done', error поясняет причину. - failure-path: run_crossload() бросает исключение → breadcrumb 'failed' + error, задача ре-рейзит исключение (Celery должен уйти в FAILURE). """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch def _make_db_mock(run_id: int = 7) -> MagicMock: """Mock SessionLocal() — INSERT ... RETURNING run_id возвращает run_id.""" db = MagicMock() db.execute.return_value.scalar_one.return_value = run_id return db def _capture_finish_run(mod: Any) -> tuple[list[dict[str, Any]], Any]: """Оборачивает mod._finish_run, чтобы записывать все вызовы (kwargs).""" calls: list[dict[str, Any]] = [] orig = mod._finish_run def capture(_db: Any, _run_id: int, **kwargs: Any) -> None: calls.append({"run_id": _run_id, **kwargs}) orig(_db, _run_id, **kwargs) return calls, capture def test_success_writes_done_breadcrumb_with_counts() -> None: """Успешный прогон: run_id создан, breadcrumb → 'done' с source_rows/upserted/skipped.""" from app.workers.tasks import etl_newbuilding_crossload as mod db = _make_db_mock(run_id=7) finish_calls, capture = _capture_finish_run(mod) with ( patch.object(mod, "SessionLocal", return_value=db), patch.object( mod, "run_crossload", return_value={"source_rows": 100, "upserted": 95, "skipped": 5}, ) as mock_run_crossload, patch.object(mod, "_finish_run", side_effect=capture), ): result = mod.etl_newbuilding_crossload.run(triggered_by="manual") assert result["run_id"] == 7 assert result["source_rows"] == 100 assert result["upserted"] == 95 assert result["skipped"] == 5 # run_crossload вызван с нашей db-сессией (переиспользуем транзакцию/savepoints). mock_run_crossload.assert_called_once_with(db=db) assert len(finish_calls) == 1 assert finish_calls[0]["run_id"] == 7 assert finish_calls[0]["status"] == "done" assert finish_calls[0]["error"] is None assert finish_calls[0]["rows_history"] == 100 assert finish_calls[0]["rows_lots"] == 95 assert finish_calls[0]["requests_count"] == 5 db.close.assert_called_once() def test_disabled_writes_done_breadcrumb_with_explanation() -> None: """TRADEIN_DATABASE_URL не задан → run_crossload вернул {"disabled": True}; breadcrumb всё равно 'done' (ожидаемое поведение, не ошибка), error поясняет причину.""" from app.workers.tasks import etl_newbuilding_crossload as mod db = _make_db_mock(run_id=11) finish_calls, capture = _capture_finish_run(mod) with ( patch.object(mod, "SessionLocal", return_value=db), patch.object(mod, "run_crossload", return_value={"disabled": True}), patch.object(mod, "_finish_run", side_effect=capture), ): result = mod.etl_newbuilding_crossload.run(triggered_by="beat") assert result == {"run_id": 11, "disabled": True} assert len(finish_calls) == 1 assert finish_calls[0]["status"] == "done" assert finish_calls[0]["error"] == "TRADEIN_DATABASE_URL not set" def test_exception_writes_failed_breadcrumb_and_reraises() -> None: """run_crossload() бросает исключение (postgres timeout / schema drift) → breadcrumb помечается 'failed' с error-сообщением, задача ре-рейзит исключение (Celery FAILURE + GlitchTip продолжает ловить сбой).""" from app.workers.tasks import etl_newbuilding_crossload as mod db = _make_db_mock(run_id=13) finish_calls, capture = _capture_finish_run(mod) with ( patch.object(mod, "SessionLocal", return_value=db), patch.object(mod, "run_crossload", side_effect=RuntimeError("tradein postgres timeout")), patch.object(mod, "_finish_run", side_effect=capture), ): try: mod.etl_newbuilding_crossload.run(triggered_by="beat") raised = False except RuntimeError as e: raised = True assert "tradein postgres timeout" in str(e) assert raised, "исключение должно ре-рейзиться, чтобы Celery ушёл в FAILURE" assert len(finish_calls) == 1 assert finish_calls[0]["run_id"] == 13 assert finish_calls[0]["status"] == "failed" assert "RuntimeError" in finish_calls[0]["error"] assert "tradein postgres timeout" in finish_calls[0]["error"] db.close.assert_called_once() def test_breadcrumb_flush_failure_does_not_swallow_original_exception() -> None: """Если сам breadcrumb-flush (_finish_run) на failure-пути тоже падает — исходное исключение из run_crossload всё равно должно ре-рейзиться, не подавляться.""" from app.workers.tasks import etl_newbuilding_crossload as mod db = _make_db_mock(run_id=21) def _boom_finish(*_a: Any, **_kw: Any) -> None: raise Exception("DB connection lost while writing breadcrumb") with ( patch.object(mod, "SessionLocal", return_value=db), patch.object( mod, "run_crossload", side_effect=RuntimeError("schema drift on tradein side") ), patch.object(mod, "_finish_run", side_effect=_boom_finish), ): try: mod.etl_newbuilding_crossload.run(triggered_by="beat") raised_type = None except Exception as e: # тест проверяет именно тип/сообщение исходного исключения raised_type = type(e) raised_msg = str(e) assert raised_type is RuntimeError assert "schema drift on tradein side" in raised_msg