Ops automation — today the ДКП ingest is manual (JOBS hardcoded to 2026Q1); Q2'26 lands ~Aug 2026. Adds a monthly scheduler job that detects a new Rosreestr quarter and logs an actionable ingest alert. Detect+alert only — does NOT download the multi-GB zip or shell out from the tick. - rosreestr_poll.py: latest_loaded_quarter (MAX deal_date) + next-quarter calc + HEAD-probe dataset availability; defensive (network err → False, never raises) - tasks/rosreestr_quarter_poll.py: task wrapper (mirrors sber_index_pull) - scheduler.py: trigger_rosreestr_quarter_poll_run + dispatch branch - migration 096: scrape_schedules monthly seed, ON CONFLICT DO NOTHING Refs #888, #727, #724
243 lines
8.4 KiB
Python
243 lines
8.4 KiB
Python
"""Unit tests for rosreestr_poll service (#888).
|
|
|
|
Coverage:
|
|
(a) _next_quarter: boundary cases (Q4 → Q1 of next year, mid-year).
|
|
(b) latest_loaded_quarter: derives correct (year, quarter) from a known deal_date.
|
|
(c) poll_rosreestr_new_quarter available=True path (mocked httpx + fake DB).
|
|
(d) poll_rosreestr_new_quarter available=False path.
|
|
(e) Network error → available=False (never raises into caller).
|
|
(f) No DB data → fallback baseline 2025Q4, checks 2026Q1.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from datetime import date
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
|
|
# WeasyPrint stub — not installed in CI without GTK
|
|
_wp_mock = MagicMock()
|
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from app.services.rosreestr_poll import ( # noqa: E402
|
|
_next_quarter,
|
|
_period_start_to_quarter,
|
|
check_new_quarter_available,
|
|
latest_loaded_quarter,
|
|
poll_rosreestr_new_quarter,
|
|
rosreestr_dataset_url,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (a) _next_quarter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_next_quarter_mid_year() -> None:
|
|
assert _next_quarter(2026, 1) == (2026, 2)
|
|
assert _next_quarter(2026, 2) == (2026, 3)
|
|
assert _next_quarter(2026, 3) == (2026, 4)
|
|
|
|
|
|
def test_next_quarter_year_rollover() -> None:
|
|
assert _next_quarter(2026, 4) == (2027, 1)
|
|
assert _next_quarter(2024, 4) == (2025, 1)
|
|
|
|
|
|
def test_period_start_to_quarter_from_date() -> None:
|
|
assert _period_start_to_quarter(date(2026, 1, 1)) == (2026, 1)
|
|
assert _period_start_to_quarter(date(2026, 4, 1)) == (2026, 2)
|
|
assert _period_start_to_quarter(date(2026, 7, 15)) == (2026, 3)
|
|
assert _period_start_to_quarter(date(2026, 10, 31)) == (2026, 4)
|
|
assert _period_start_to_quarter(date(2025, 12, 31)) == (2025, 4)
|
|
|
|
|
|
def test_period_start_to_quarter_from_string() -> None:
|
|
assert _period_start_to_quarter("2026-01-15") == (2026, 1)
|
|
assert _period_start_to_quarter("2026-07-01") == (2026, 3)
|
|
|
|
|
|
def test_rosreestr_dataset_url_contains_year_quarter() -> None:
|
|
url = rosreestr_dataset_url(2026, 2)
|
|
assert "2026" in url
|
|
assert "_2" in url or "q_2" in url # quarter embedded
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fake DB helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeRow:
|
|
"""Mimics a DB row with __getitem__."""
|
|
|
|
def __init__(self, val: object) -> None:
|
|
self._val = val
|
|
|
|
def __getitem__(self, idx: int) -> object:
|
|
return self._val
|
|
|
|
|
|
class _FakeDB:
|
|
"""Minimal fake Session returning a configurable deal_date via execute().fetchone()."""
|
|
|
|
def __init__(self, max_deal_date: date | None) -> None:
|
|
self._row = _FakeRow(max_deal_date) if max_deal_date is not None else None
|
|
|
|
def execute(self, stmt: object, params: object = None) -> MagicMock:
|
|
mock = MagicMock()
|
|
mock.fetchone.return_value = self._row
|
|
return mock
|
|
|
|
def commit(self) -> None:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (b) latest_loaded_quarter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_latest_loaded_quarter_returns_correct_year_quarter() -> None:
|
|
"""Q1 2026 deal_date → (2026, 1)."""
|
|
db = _FakeDB(date(2026, 1, 31))
|
|
result = latest_loaded_quarter(db) # type: ignore[arg-type]
|
|
assert result == (2026, 1)
|
|
|
|
|
|
def test_latest_loaded_quarter_q4() -> None:
|
|
"""Dec deal_date → Q4."""
|
|
db = _FakeDB(date(2025, 12, 15))
|
|
result = latest_loaded_quarter(db) # type: ignore[arg-type]
|
|
assert result == (2025, 4)
|
|
|
|
|
|
def test_latest_loaded_quarter_returns_none_when_empty() -> None:
|
|
"""No rows → None."""
|
|
db = _FakeDB(None)
|
|
result = latest_loaded_quarter(db) # type: ignore[arg-type]
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (c) poll_rosreestr_new_quarter — available=True
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_available_true_returns_correct_next_quarter() -> None:
|
|
"""If latest loaded is Q1 2026 and head returns 200, returns available=True for Q2 2026."""
|
|
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.head.return_value = mock_response
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
|
|
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
|
|
|
|
assert result["available"] is True
|
|
assert result["year"] == 2026
|
|
assert result["quarter"] == 2
|
|
assert result["latest_loaded_year"] == 2026
|
|
assert result["latest_loaded_quarter"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (d) poll_rosreestr_new_quarter — available=False (404)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_available_false_on_404() -> None:
|
|
"""404 from server → available=False."""
|
|
db = _FakeDB(date(2026, 3, 15)) # Q1 2026
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 404
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.head.return_value = mock_response
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
|
|
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
|
|
|
|
assert result["available"] is False
|
|
assert result["year"] == 2026
|
|
assert result["quarter"] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (e) Network error → available=False (never raises into caller)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_network_error_returns_false_no_raise() -> None:
|
|
"""Network error → available=False, no exception propagated."""
|
|
import httpx
|
|
|
|
db = _FakeDB(date(2026, 1, 20)) # Q1 2026
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.head.side_effect = httpx.ConnectError("simulated network error")
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
|
|
# Must not raise
|
|
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
|
|
|
|
assert result["available"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_new_quarter_available_timeout_returns_false() -> None:
|
|
"""Timeout → returns False, no exception."""
|
|
import httpx
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.head.side_effect = httpx.TimeoutException("timeout")
|
|
|
|
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
|
|
assert result is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (f) No DB data → fallback baseline checks 2026Q1
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_fallback_when_no_db_data() -> None:
|
|
"""No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1."""
|
|
db = _FakeDB(None)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 404 # not yet available
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.head.return_value = mock_response
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
|
|
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
|
|
|
|
# Fallback: 2025Q4 → check 2026Q1
|
|
assert result["year"] == 2026
|
|
assert result["quarter"] == 1
|
|
# latest_loaded_year/quarter should be None (no DB data)
|
|
assert result["latest_loaded_year"] is None
|
|
assert result["latest_loaded_quarter"] is None
|