gendesign/tradein-mvp/backend/tests/test_rosreestr_poll.py
bot-backend aaf2bacfa3 feat(tradein): scheduled monthly Rosreestr new-quarter poll → ingest alert (#888)
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
2026-05-31 15:58:03 +03:00

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