Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
Adds period_type TEXT NOT NULL DEFAULT 'unknown' to macro_indicator and
widens the PRIMARY KEY to (indicator_type, region, obs_date, period_type).
Before: yearly aggregate ('год'→obs_date YYYY-01-01) and Q1 ('I квартал'→
same date) shared the same PK slot → ON CONFLICT DO UPDATE made them
overwrite each other despite the in-memory dedup fix in #1687.
After: each EmissRow carries period_type from _emiss_period_granularity
('year'/'quarter'/'month'); DB ON CONFLICT targets include it, so
yearly+Q1 rows genuinely coexist.
Non-EMISS sources (CBR, rosstat open-data, domrf) use period_type='unknown'
(literal in SQL) — they are disambiguated by obs_date already, so the
wider PK is backward-compatible. All ON CONFLICT clauses in
cbr_macro_sync.py and rosstat_macro_sync.py updated accordingly.
Migration: 163_emiss_pk_period_type.sql (idempotent BEGIN/COMMIT).
Tests: 49 passed (emiss + rosstat suite), ruff clean, py_compile OK.
316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""Тесты Celery-таски rosstat_macro_sync (mock session + mock fetch, без живой сети).
|
||
|
||
Покрывают: контракт upsert-SQL обоих источников (source='rosstat'/'emiss', ON CONFLICT,
|
||
CAST not ::), счётчики fetched/upserted (open-data + ЕМИСС), per-source guard (сбой
|
||
одного источника НЕ мешает другому, но пробрасывается), регистрацию в beat (ежемесячно).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
|
||
def _make_mock_db() -> tuple[MagicMock, list[dict[str, Any]]]:
|
||
"""Mock DB session с поддержкой db.begin_nested() context manager (SAVEPOINT).
|
||
|
||
Возвращает (db, captured_params) — captured содержит params каждого
|
||
db.execute(..., params).
|
||
"""
|
||
captured: list[dict[str, Any]] = []
|
||
|
||
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
|
||
if params is not None:
|
||
captured.append(params)
|
||
return MagicMock()
|
||
|
||
db = MagicMock()
|
||
db.execute = execute_router
|
||
db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
|
||
db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
|
||
return db, captured
|
||
|
||
|
||
def test_upsert_sql_contract() -> None:
|
||
"""UPSERT_ROSSTAT_SQL содержит обязательные литералы контракта macro_indicator
|
||
и НЕ использует :x::type (psycopg v3). PK включает period_type (migration 163)."""
|
||
from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_SQL
|
||
|
||
sql = str(UPSERT_ROSSTAT_SQL)
|
||
upper = sql.upper()
|
||
|
||
assert "INTO MACRO_INDICATOR" in upper
|
||
assert "'rosstat'" in sql
|
||
assert "'yearly'" in sql
|
||
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper
|
||
assert "VALUE = EXCLUDED.VALUE" in upper
|
||
assert "UPDATED_AT = NOW()" in upper
|
||
# psycopg v3: CAST(:x AS type), НИКОГДА :x::type
|
||
assert "CAST(:ITYPE AS TEXT)" in upper
|
||
assert "CAST(:D AS DATE)" in upper
|
||
assert "CAST(:V AS NUMERIC)" in upper
|
||
assert "::" not in sql
|
||
# не-ЕМИСС строки используют period_type='unknown' (литерал в SQL)
|
||
assert "'unknown'" in sql
|
||
|
||
|
||
def test_task_upserts_each_row_with_correct_params() -> None:
|
||
"""Каждая MacroRow → один execute с bind-параметрами itype/region/d/v/unit/comment."""
|
||
from app.services.scrapers.rosstat_emiss import MacroRow
|
||
from app.workers.tasks import rosstat_macro_sync as task_mod
|
||
|
||
db, captured = _make_mock_db()
|
||
fake_rows = [
|
||
MacroRow("population_total", "sverdl", date(2014, 1, 1), Decimal("4320677"), "чел", "c1"),
|
||
MacroRow("population_total", "ekb", date(2014, 1, 1), Decimal("1412346"), "чел", "c2"),
|
||
]
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "fetch_all_datasets", return_value=fake_rows) as mock_fetch,
|
||
patch.object(task_mod, "fetch_all_emiss", return_value=[]) as mock_emiss,
|
||
patch.object(task_mod, "fetch_construction_index", return_value=[]) as mock_constr,
|
||
):
|
||
result = task_mod.rosstat_macro_sync.run()
|
||
|
||
mock_fetch.assert_called_once()
|
||
mock_emiss.assert_called_once()
|
||
mock_constr.assert_called_once()
|
||
assert result["fetched"] == 2
|
||
assert result["upserted"] == 2
|
||
assert result["rosstat"] == {"fetched": 2, "upserted": 2}
|
||
assert result["emiss"] == {"fetched": 0, "upserted": 0}
|
||
assert result["construction"] == {"fetched": 0, "upserted": 0}
|
||
assert captured == [
|
||
{
|
||
"itype": "population_total",
|
||
"region": "sverdl",
|
||
"d": date(2014, 1, 1),
|
||
"v": Decimal("4320677"),
|
||
"unit": "чел",
|
||
"comment": "c1",
|
||
},
|
||
{
|
||
"itype": "population_total",
|
||
"region": "ekb",
|
||
"d": date(2014, 1, 1),
|
||
"v": Decimal("1412346"),
|
||
"unit": "чел",
|
||
"comment": "c2",
|
||
},
|
||
]
|
||
db.commit.assert_called()
|
||
db.close.assert_called_once()
|
||
|
||
|
||
def test_task_reraises_fetch_failure() -> None:
|
||
"""Ошибка open-data fetch'а пробрасывается (surfaces в Celery/GlitchTip), DB закрывается.
|
||
|
||
Even with the per-source guard, ошибка ПОСЛЕ попытки обоих источников re-raise'ится.
|
||
"""
|
||
from app.workers.tasks import rosstat_macro_sync as task_mod
|
||
|
||
db, _ = _make_mock_db()
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "fetch_all_datasets", side_effect=RuntimeError("rosstat down")),
|
||
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
|
||
patch.object(task_mod, "fetch_construction_index", return_value=[]),
|
||
):
|
||
try:
|
||
task_mod.rosstat_macro_sync.run()
|
||
raise AssertionError("expected RuntimeError to propagate")
|
||
except RuntimeError as e:
|
||
assert "rosstat down" in str(e)
|
||
|
||
db.close.assert_called_once()
|
||
|
||
|
||
def test_task_emiss_failure_does_not_block_opendata() -> None:
|
||
"""Per-source guard: сбой ЕМИСС НЕ мешает open-data апсертнуться, но ошибка пробрасывается."""
|
||
from app.services.scrapers.rosstat_emiss import MacroRow
|
||
from app.workers.tasks import rosstat_macro_sync as task_mod
|
||
|
||
db, captured = _make_mock_db()
|
||
rosstat_rows = [
|
||
MacroRow("population_total", "sverdl", date(2014, 1, 1), Decimal("4320677"), "чел", "c"),
|
||
]
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "fetch_all_datasets", return_value=rosstat_rows),
|
||
patch.object(task_mod, "fetch_all_emiss", side_effect=RuntimeError("emiss down")),
|
||
patch.object(task_mod, "fetch_construction_index", return_value=[]),
|
||
):
|
||
try:
|
||
task_mod.rosstat_macro_sync.run()
|
||
raise AssertionError("expected RuntimeError to propagate")
|
||
except RuntimeError as e:
|
||
assert "emiss down" in str(e)
|
||
|
||
# open-data всё равно апсертнулся ДО проброса emiss-ошибки
|
||
assert any(p.get("itype") == "population_total" for p in captured)
|
||
db.close.assert_called_once()
|
||
|
||
|
||
def test_task_upserts_emiss_rows_with_correct_params() -> None:
|
||
"""Каждая EmissRow → execute UPSERT_EMISS_SQL с itype/region/d/period_type/v/freq/unit/comment.
|
||
|
||
period_type передаётся из r.period_type (часть нового PK, migration 163).
|
||
"""
|
||
from app.services.scrapers.rosstat_emiss import EmissRow
|
||
from app.workers.tasks import rosstat_macro_sync as task_mod
|
||
|
||
db, captured = _make_mock_db()
|
||
emiss_rows = [
|
||
EmissRow(
|
||
"income_per_capita",
|
||
"sverdl",
|
||
date(2024, 1, 1),
|
||
Decimal("54006"),
|
||
"руб",
|
||
"quarterly",
|
||
"c",
|
||
period_type="quarter",
|
||
),
|
||
]
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "fetch_all_datasets", return_value=[]),
|
||
patch.object(task_mod, "fetch_all_emiss", return_value=emiss_rows),
|
||
patch.object(task_mod, "fetch_construction_index", return_value=[]),
|
||
):
|
||
result = task_mod.rosstat_macro_sync.run()
|
||
|
||
assert result["emiss"] == {"fetched": 1, "upserted": 1}
|
||
assert captured == [
|
||
{
|
||
"itype": "income_per_capita",
|
||
"region": "sverdl",
|
||
"d": date(2024, 1, 1),
|
||
"period_type": "quarter",
|
||
"v": Decimal("54006"),
|
||
"freq": "quarterly",
|
||
"unit": "руб",
|
||
"comment": "c",
|
||
},
|
||
]
|
||
|
||
|
||
def test_upsert_emiss_sql_contract() -> None:
|
||
"""UPSERT_EMISS_SQL: source='emiss', frequency+period_type параметризованы,
|
||
ON CONFLICT включает period_type (migration 163), CAST not ::."""
|
||
from app.workers.tasks.rosstat_macro_sync import UPSERT_EMISS_SQL
|
||
|
||
sql = str(UPSERT_EMISS_SQL)
|
||
upper = sql.upper()
|
||
assert "INTO MACRO_INDICATOR" in upper
|
||
assert "'emiss'" in sql
|
||
assert "CAST(:FREQ AS TEXT)" in upper
|
||
assert "CAST(:PERIOD_TYPE AS TEXT)" in upper
|
||
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper
|
||
assert "CAST(:ITYPE AS TEXT)" in upper
|
||
assert "CAST(:V AS NUMERIC)" in upper
|
||
assert "::" not in sql
|
||
|
||
|
||
def test_upsert_rosstat_monthly_sql_contract() -> None:
|
||
"""UPSERT_ROSSTAT_MONTHLY_SQL: source='rosstat', frequency='monthly', period_type='unknown',
|
||
ON CONFLICT включает period_type (migration 163), CAST not ::."""
|
||
from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_MONTHLY_SQL
|
||
|
||
sql = str(UPSERT_ROSSTAT_MONTHLY_SQL)
|
||
upper = sql.upper()
|
||
assert "INTO MACRO_INDICATOR" in upper
|
||
assert "'rosstat'" in sql
|
||
assert "'monthly'" in sql
|
||
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper
|
||
assert "CAST(:ITYPE AS TEXT)" in upper
|
||
assert "CAST(:D AS DATE)" in upper
|
||
assert "CAST(:V AS NUMERIC)" in upper
|
||
assert "::" not in sql
|
||
assert "'unknown'" in sql
|
||
|
||
|
||
def test_task_upserts_construction_rows_with_correct_params() -> None:
|
||
"""Каждая construction MacroRow → execute UPSERT_ROSSTAT_MONTHLY_SQL c bind-параметрами."""
|
||
from app.services.scrapers.rosstat_emiss import MacroRow
|
||
from app.workers.tasks import rosstat_macro_sync as task_mod
|
||
|
||
db, captured = _make_mock_db()
|
||
constr_rows = [
|
||
MacroRow(
|
||
"construction_price_index",
|
||
"rf",
|
||
date(2025, 1, 1),
|
||
Decimal("100.8"),
|
||
"%",
|
||
"smr",
|
||
),
|
||
]
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "fetch_all_datasets", return_value=[]),
|
||
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
|
||
patch.object(task_mod, "fetch_construction_index", return_value=constr_rows),
|
||
):
|
||
result = task_mod.rosstat_macro_sync.run()
|
||
|
||
assert result["construction"] == {"fetched": 1, "upserted": 1}
|
||
assert result["fetched"] == 1
|
||
assert captured == [
|
||
{
|
||
"itype": "construction_price_index",
|
||
"region": "rf",
|
||
"d": date(2025, 1, 1),
|
||
"v": Decimal("100.8"),
|
||
"unit": "%",
|
||
"comment": "smr",
|
||
},
|
||
]
|
||
|
||
|
||
def test_task_construction_failure_does_not_block_others() -> None:
|
||
"""Per-source guard: сбой СМР-источника НЕ мешает open-data/ЕМИСС, ошибка пробрасывается."""
|
||
from app.services.scrapers.rosstat_emiss import MacroRow
|
||
from app.workers.tasks import rosstat_macro_sync as task_mod
|
||
|
||
db, captured = _make_mock_db()
|
||
rosstat_rows = [
|
||
MacroRow("population_total", "sverdl", date(2014, 1, 1), Decimal("4320677"), "чел", "c"),
|
||
]
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "fetch_all_datasets", return_value=rosstat_rows),
|
||
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
|
||
patch.object(
|
||
task_mod, "fetch_construction_index", side_effect=RuntimeError("rosstat xlsx down")
|
||
),
|
||
):
|
||
try:
|
||
task_mod.rosstat_macro_sync.run()
|
||
raise AssertionError("expected RuntimeError to propagate")
|
||
except RuntimeError as e:
|
||
assert "rosstat xlsx down" in str(e)
|
||
|
||
# open-data всё равно апсертнулся ДО проброса construction-ошибки
|
||
assert any(p.get("itype") == "population_total" for p in captured)
|
||
db.close.assert_called_once()
|
||
|
||
|
||
def test_task_registered_in_beat_monthly() -> None:
|
||
"""Задача зарегистрирована в beat как ежемесячная (1-е число)."""
|
||
from app.workers.beat_schedule import build_beat_schedule
|
||
|
||
schedule = build_beat_schedule()
|
||
assert "rosstat-macro-sync-monthly" in schedule
|
||
entry = schedule["rosstat-macro-sync-monthly"]
|
||
assert entry["task"] == "tasks.rosstat_macro_sync.rosstat_macro_sync"
|
||
# crontab day_of_month = 1 (1-е число месяца)
|
||
assert 1 in entry["schedule"].day_of_month
|