"""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