Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
157 lines
6.7 KiB
Python
157 lines
6.7 KiB
Python
"""Тесты 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
|