fix(tradein): repoint rosreestr_poll to /data-sets/ + verify Content-Type (СДЕЛКИ dataset moved off /opendata) #2477

Merged
bot-backend merged 1 commit from fix/rosreestr-poll-datasets-repoint into main 2026-07-12 13:21:59 +00:00
2 changed files with 462 additions and 97 deletions

View file

@ -5,31 +5,62 @@
открытых данных Росреестра.
- ALERT: залогировать actionable-сообщение, если квартал доступен.
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders это ручной
ops-шаг. После алерта оператор запускает:
ops-шаг. После алерта оператор запускает (оба скрипта живут вне этого
репозитория при смене базового пути их тоже нужно поправить вручную):
data/sql/02_load_all_quarters.sh
tradein-mvp/deploy/import-rosreestr.sh
Архив открытых данных Росреестра (сделки ДКП/ДДУ) публикуется на портале:
https://rosreestr.gov.ru/opendata/
ИСТОРИЯ (важно для будущих правок): раньше архив открытых данных Росреестра
(сделки ДКП/ДДУ) публиковался на https://rosreestr.gov.ru/opendata/ с индексом
/opendata/f.json. Этот портал был переделан на Bitrix прямые ссылки на ZIP
теперь отвечают HTTP 200 с Content-Type: text/html (soft-404 "заглушка"), а
/opendata/f.json больше не отдаёт JSON. Старая версия поллера трактовала любой
HTTP 200 как "квартал доступен" и из-за soft-404 стала ложно репортить КАЖДЫЙ
квартал как доступный это и есть баг #issue, который чинит этот модуль.
Файлы именуются по шаблону:
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
АКТУАЛЬНОЕ (2026-07) расположение датасетов открытый Apache autoindex без
авторизации:
https://rosreestr.gov.ru/data-sets/
Структура:
- Папки кварталов названы в кодировке Windows-1251 (percent-encoded href
именно cp1251, НЕ utf-8), например "1 квартал 2026г."
href="1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./". Плюс папка
"Архив до 2023г. включительно/" для старых периодов.
- Внутри папки квартала лежит dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
(тоже cp1251-кодированный href, например
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"), рядом
dataset_КАДАСТРСТОИМОСТЬ_* файлы (не наши).
Индексный JSON живёт по адресу:
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов)
Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок
расставляют руками, возможны отличия в пробелах/пунктуации между кварталами)
поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый
percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")),
затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном
имени. См. check_new_quarter_available().
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал.
Формат ссылки:
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный
файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет
Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200
недостаточен.
При сетевой ошибке / HTTP 5xx логируем warning, возвращаем available=False.
Статус 404 квартал не опубликован available=False (штатный случай до начала августа).
rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет
доверия в стандартном trust store соединение делаем с verify=False, как
sber_index.py для sberindex.ru (см. #922, тот же паттерн: публичные
неавторизованные открытые данные, TLS verify отключаем осознанно). Сервер также
отвечает HTTP 403 без браузерного User-Agent шлём Chrome UA (тот же паттерн,
что DEFAULT_UA в zhkh_flats_loader.py).
При сетевой ошибке / HTTP 5xx / таймауте логируем warning, возвращаем
available=False. Отсутствие папки/файла квартала available=False (штатный
случай до публикации квартала, до начала следующего месяца после конца квартала).
"""
from __future__ import annotations
import logging
import re
from typing import Any
from urllib.parse import quote, unquote, urljoin
import httpx
from sqlalchemy import text
@ -37,16 +68,24 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала).
# Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru.
_ROSREESTR_DATASET_URL_TEMPLATE = (
"https://rosreestr.gov.ru/opendata/"
"dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
# Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля).
_DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/"
# Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403.
# Тот же паттерн, что DEFAULT_UA в app/services/zhkh_flats_loader.py.
_BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
# Таймаут HEAD-запроса. Росреестр может быть медленным — 15s достаточно.
# Порог отличия реального ZIP-архива от soft-404 заглушки/пустого файла.
_MIN_DATASET_SIZE_BYTES = 100_000 # 100 KB
# Таймаут запросов. Росреестр может быть медленным — 15s достаточно.
_HTTP_TIMEOUT = 15.0
_HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE)
def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
"""Вернуть (year, quarter) для следующего квартала.
@ -117,11 +156,68 @@ def latest_loaded_quarter(db: Session) -> tuple[int, int] | None:
def rosreestr_dataset_url(year: int, quarter: int) -> str:
"""Сформировать URL датасета Росреестра для заданного квартала.
"""Сформировать best-effort прямую ссылку на ZIP датасета (для alert-сообщения оператору).
Возвращает прямую ссылку на ZIP-архив (percent-encoded шаблон).
ВНИМАНИЕ: это ДЕТЕРМИНИРОВАННАЯ догадка по известному шаблону именования
папка "{quarter} квартал {year}г.", файл
"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip" закодированная как
cp1251 percent-encoding (подтверждено вручную против реального URL портала).
Она НЕ используется для самой проверки доступности: check_new_quarter_available()
парсит реальный autoindex и не полагается на угаданный URL, потому что
Росреестр расставляет имена папок вручную и они могут отличаться в мелочах
(лишний пробел, пунктуация) от квартала к кварталу.
Если угаданная ссылка не откроется ориентир для оператора: _DATA_SETS_BASE_URL
(https://rosreestr.gov.ru/data-sets/), там нужно найти папку глазами.
"""
return _ROSREESTR_DATASET_URL_TEMPLATE.format(year=year, quarter=quarter)
folder_name = f"{quarter} квартал {year}г."
file_name = f"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
folder_enc = quote(folder_name.encode("cp1251"), safe="")
file_enc = quote(file_name.encode("cp1251"), safe="")
return f"{_DATA_SETS_BASE_URL}{folder_enc}/{file_enc}"
def _decode_cp1251_href(href: str) -> str:
"""Декодировать percent-encoded href как cp1251 (портал кодирует кириллицу так, НЕ utf-8)."""
try:
return unquote(href, encoding="cp1251", errors="strict")
except (UnicodeDecodeError, LookupError):
# href без кириллицы (например "../") или неожиданная кодировка — fallback.
return unquote(href)
def _normalize_ws(s: str) -> str:
"""Схлопнуть повторяющиеся пробелы — Росреестр расставляет имена папок вручную."""
return " ".join(s.split())
def _extract_hrefs(html: str) -> list[str]:
"""Достать все href="..." из HTML autoindex-страницы (простой regex, не наш HTML)."""
return _HREF_RE.findall(html)
def _find_quarter_folder_href(html: str, year: int, quarter: int) -> str | None:
"""Найти (не декодированный) href папки квартала (year, quarter) в HTML /data-sets/.
Сравнение по декодированному (cp1251) и нормализованному по пробелам имени
папки, ожидаемый паттерн "{quarter} квартал {year}г.".
"""
expected_name = f"{quarter} квартал {year}г."
for href in _extract_hrefs(html):
decoded = _normalize_ws(_decode_cp1251_href(href).rstrip("/"))
if decoded == expected_name:
return href
return None
def _find_dataset_file_href(html: str, year: int, quarter: int) -> str | None:
"""Найти href файла dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip в HTML папки квартала."""
expected_suffix = f"_y_{year}_q_{quarter}.csv.zip"
for href in _extract_hrefs(html):
decoded = _decode_cp1251_href(href)
if decoded.startswith("dataset_СДЕЛКИ") and decoded.endswith(expected_suffix):
return href
return None
async def check_new_quarter_available(
@ -129,48 +225,109 @@ async def check_new_quarter_available(
year: int,
quarter: int,
) -> bool:
"""Проверить, опубликован ли датасет Росреестра для (year, quarter).
"""Проверить, опубликован ли датасет СДЕЛКИ Росреестра для (year, quarter).
HEAD-запрос к прямой ссылке на ZIP без скачивания файла.
Шаги (см. докстринг модуля за подробностями):
1. GET autoindex _DATA_SETS_BASE_URL, найти папку квартала по декодированному
(cp1251) имени "{quarter} квартал {year}г.".
2. GET найденную папку, найти файл dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip.
3. HEAD найденный файл считаем ДОСТУПНЫМ, только если Content-Type
начинается с application/zip И Content-Length > _MIN_DATASET_SIZE_BYTES.
Голый HTTP 200 НЕ считается доступностью это была причина бага
(Bitrix-заглушка /opendata/ тоже отвечала 200).
Возвращает True если сервер ответил 200 (или 2xx/3xx с redirect).
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке.
Отсутствие папки/файла квартала available=False (нормально до публикации).
Сетевая ошибка / таймаут / неожиданное исключение warning, available=False.
Никогда не поднимает исключения в вызывающий код (scheduler-safe).
"""
url = rosreestr_dataset_url(year, quarter)
try:
resp = await client.head(url, follow_redirects=True)
if resp.status_code == 200:
logger.info(
"rosreestr_poll: Q%d %d available at %s (HTTP %d)",
quarter,
year,
url,
resp.status_code,
)
return True
if resp.status_code == 404:
logger.info(
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
if index_resp.status_code != 200:
logger.warning(
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
index_resp.status_code,
_DATA_SETS_BASE_URL,
quarter,
year,
)
return False
# Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем
logger.warning(
"rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable",
resp.status_code,
folder_href = _find_quarter_folder_href(index_resp.text, year, quarter)
if folder_href is None:
logger.info(
"rosreestr_poll: quarter folder for Q%d %d not found under %s — not yet published",
quarter,
year,
_DATA_SETS_BASE_URL,
)
return False
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
folder_resp = await client.get(folder_url, follow_redirects=True)
if folder_resp.status_code != 200:
logger.warning(
"rosreestr_poll: unexpected HTTP %d listing folder %s"
"treating Q%d %d as unavailable",
folder_resp.status_code,
folder_url,
quarter,
year,
)
return False
file_href = _find_dataset_file_href(folder_resp.text, year, quarter)
if file_href is None:
logger.info(
"rosreestr_poll: dataset_СДЕЛКИ file for Q%d %d not found in folder %s",
quarter,
year,
folder_url,
)
return False
file_url = urljoin(folder_url, file_href)
file_resp = await client.head(file_url, follow_redirects=True)
content_type = file_resp.headers.get("content-type", "")
try:
content_length = int(file_resp.headers.get("content-length") or "0")
except ValueError:
content_length = 0
if (
file_resp.status_code == 200
and content_type.startswith("application/zip")
and content_length > _MIN_DATASET_SIZE_BYTES
):
logger.info(
"rosreestr_poll: Q%d %d available at %s (HTTP 200, %s, %d bytes)",
quarter,
year,
file_url,
content_type,
content_length,
)
return True
logger.info(
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
"treating as unavailable",
quarter,
year,
url,
file_url,
file_resp.status_code,
content_type,
content_length,
)
return False
except httpx.TimeoutException:
logger.warning(
"rosreestr_poll: timeout checking Q%d %d url=%s — treating as unavailable",
"rosreestr_poll: timeout checking Q%d %d under %s — treating as unavailable",
quarter,
year,
url,
_DATA_SETS_BASE_URL,
)
return False
except httpx.RequestError as exc:
@ -197,7 +354,7 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
Шаги:
1. Найти MAX(deal_date) WHERE source='rosreestr' (loaded_year, loaded_quarter).
2. Вычислить next = _next_quarter(loaded_year, loaded_quarter).
3. HEAD-запросом проверить наличие датасета на rosreestr.gov.ru.
3. Проверить наличие датасета на rosreestr.gov.ru/data-sets/ (autoindex + Content-Type).
4. Если доступен логировать actionable INFO с инструкцией по запуску ingest.
5. Вернуть dict с результатом.
@ -230,18 +387,27 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
loaded_year,
)
# 3. Проверка наличия
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client:
# 3. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store;
# браузерный UA: без него rosreestr.gov.ru отвечает 403)
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
verify=False,
headers={"User-Agent": _BROWSER_USER_AGENT},
) as client:
available = await check_new_quarter_available(client, next_year, next_quarter)
# 4. Алерт если доступен
if available:
logger.info(
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. "
"Run ingest to load: "
"data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.sh",
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d (%s). "
"Run ingest to load: data/sql/02_load_all_quarters.sh + "
"tradein-mvp/deploy/import-rosreestr.sh "
"(both live outside this repo — if they still point at the old "
"rosreestr.gov.ru/opendata/ base, update them to %s first)",
next_quarter,
next_year,
rosreestr_dataset_url(next_year, next_quarter),
_DATA_SETS_BASE_URL,
)
return {

View file

@ -3,10 +3,17 @@
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.
(c) rosreestr_dataset_url: cp1251 percent-encoding matches the real portal URL.
(d) check_new_quarter_available new /data-sets/ autoindex flow:
- real application/zip + large Content-Length True
- soft-404 regression: file found but Content-Type text/html False
(this is the exact bug that broke the old /opendata/ flat-URL poller)
- tiny Content-Length (placeholder/error file) False
- quarter folder missing from autoindex False (not yet published)
- dataset file missing from quarter folder False
- network error / timeout False, never raises
(e) poll_rosreestr_new_quarter orchestration: available=True/False end-to-end,
no DB data fallback, network error propagation.
"""
from __future__ import annotations
@ -15,6 +22,7 @@ import os
import sys
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import quote
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -33,6 +41,46 @@ from app.services.rosreestr_poll import ( # noqa: E402
rosreestr_dataset_url,
)
# ---------------------------------------------------------------------------
# cp1251 href fixtures — built the same way the real portal encodes folder/file
# names (percent-encoded cp1251 bytes), so tests exercise the actual decode path.
# ---------------------------------------------------------------------------
def _cp1251_href(name: str) -> str:
return quote(name.encode("cp1251"), safe="")
def _autoindex_html(*folder_names: str) -> str:
"""Build a fake Apache-autoindex-style HTML listing for /data-sets/."""
links = ['<a href="../">../</a>']
for name in folder_names:
href = _cp1251_href(name) + "/"
links.append(f'<a href="{href}">{name}/</a>')
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
def _folder_html(*file_names: str) -> str:
"""Build a fake Apache-autoindex-style HTML listing for a quarter folder."""
links = ['<a href="../">../</a>']
for name in file_names:
href = _cp1251_href(name)
links.append(f'<a href="{href}">{name}</a>')
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
_SDELKI_FILE_2026_Q1 = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv.zip"
_KADASTR_FILE_2026_Q1 = "dataset_КАДАСТРСТОИМОСТЬ_r-r_01-92_y_2026_q_1.csv.zip"
def _mock_response(*, status_code: int = 200, text: str = "", headers: dict | None = None):
resp = MagicMock()
resp.status_code = status_code
resp.text = text
resp.headers = headers or {}
return resp
# ---------------------------------------------------------------------------
# (a) _next_quarter
# ---------------------------------------------------------------------------
@ -62,10 +110,25 @@ def test_period_start_to_quarter_from_string() -> None:
assert _period_start_to_quarter("2026-07-01") == (2026, 3)
# ---------------------------------------------------------------------------
# (c) rosreestr_dataset_url — cp1251 percent-encoding must match the real portal.
# ---------------------------------------------------------------------------
def test_rosreestr_dataset_url_matches_verified_real_encoding() -> None:
"""Encoding must byte-match the live-verified rosreestr.gov.ru/data-sets/ URL."""
url = rosreestr_dataset_url(2026, 1)
assert url == (
"https://rosreestr.gov.ru/data-sets/"
"1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./"
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"
)
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
assert "q_2" in url
# ---------------------------------------------------------------------------
@ -125,22 +188,181 @@ def test_latest_loaded_quarter_returns_none_when_empty() -> None:
# ---------------------------------------------------------------------------
# (c) poll_rosreestr_new_quarter — available=True
# (d) check_new_quarter_available — /data-sets/ autoindex flow
# ---------------------------------------------------------------------------
@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
async def test_check_available_true_for_real_zip() -> None:
"""Folder found, file found, HEAD returns application/zip + large Content-Length → True."""
index_html = _autoindex_html("1 квартал 2026г.", "4 квартал 2025г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1, _KADASTR_FILE_2026_Q1)
mock_client = AsyncMock()
mock_client.head.return_value = mock_response
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
headers={"content-type": "application/zip", "content-length": "9500000"}
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is True
assert mock_client.get.call_count == 2
assert mock_client.head.call_count == 1
@pytest.mark.asyncio
async def test_check_available_false_soft_404_text_html() -> None:
"""Regression guard: HTTP 200 with Content-Type text/html (Bitrix soft-404) → False.
This is the exact failure mode that broke the old /opendata/ flat-URL poller
it treated bare HTTP 200 as availability. Even if a matching file href is found,
a non-zip Content-Type must never be treated as available.
"""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
status_code=200,
headers={"content-type": "text/html; charset=utf-8", "content-length": "1234"},
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_available_false_tiny_content_length() -> None:
"""application/zip but tiny Content-Length (placeholder/error file) → False."""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
headers={"content-type": "application/zip", "content-length": "512"}
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_available_false_quarter_folder_missing() -> None:
"""Autoindex doesn't have the target quarter folder yet → False, no further GET/HEAD."""
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not published yet
mock_client = AsyncMock()
mock_client.get.return_value = _mock_response(text=index_html)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
assert mock_client.get.call_count == 1 # never fetched a (non-existent) folder
mock_client.head.assert_not_called()
@pytest.mark.asyncio
async def test_check_available_false_dataset_file_missing_in_folder() -> None:
"""Quarter folder exists but has no dataset_СДЕЛКИ file (only КАДАСТРСТОИМОСТЬ) → False."""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_KADASTR_FILE_2026_Q1) # no СДЕЛКИ file
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
mock_client.head.assert_not_called()
@pytest.mark.asyncio
async def test_check_available_false_on_index_404() -> None:
"""Unexpected non-200 on the base autoindex → False, no raise."""
mock_client = AsyncMock()
mock_client.get.return_value = _mock_response(status_code=404, text="")
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result 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.get.side_effect = httpx.TimeoutException("timeout")
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_new_quarter_available_network_error_returns_false() -> None:
"""Network error mid-flow (folder GET) → returns False, no exception."""
import httpx
index_html = _autoindex_html("1 квартал 2026г.")
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
httpx.ConnectError("simulated network error"),
]
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
# ---------------------------------------------------------------------------
# (e) poll_rosreestr_new_quarter — orchestration
# ---------------------------------------------------------------------------
def _patched_client_for(index_html: str, folder_html: str, head_response) -> AsyncMock:
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = head_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
return mock_client
@pytest.mark.asyncio
async def test_poll_available_true_returns_correct_next_quarter() -> None:
"""If latest loaded is Q1 2026 and the Q2 2026 dataset is a real zip, available=True."""
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026 → next = Q2 2026
index_html = _autoindex_html("2 квартал 2026г.")
file_name = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv.zip"
folder_html = _folder_html(file_name)
head_response = _mock_response(
headers={"content-type": "application/zip", "content-length": "9500000"}
)
mock_client = _patched_client_for(index_html, folder_html, head_response)
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
@ -152,21 +374,15 @@ async def test_poll_available_true_returns_correct_next_quarter() -> None:
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
async def test_poll_available_false_when_folder_not_published() -> None:
"""Quarter folder not yet published (autoindex has no matching entry) → available=False."""
db = _FakeDB(date(2026, 3, 15)) # Q1 2026 → next = Q2 2026
mock_response = MagicMock()
mock_response.status_code = 404
index_html = _autoindex_html("1 квартал 2026г.") # Q2 2026 missing
mock_client = AsyncMock()
mock_client.head.return_value = mock_response
mock_client.get.return_value = _mock_response(text=index_html)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
@ -178,11 +394,6 @@ async def test_poll_available_false_on_404() -> None:
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."""
@ -191,7 +402,7 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
db = _FakeDB(date(2026, 1, 20)) # Q1 2026
mock_client = AsyncMock()
mock_client.head.side_effect = httpx.ConnectError("simulated network error")
mock_client.get.side_effect = httpx.ConnectError("simulated network error")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
@ -202,18 +413,6 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
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
# ---------------------------------------------------------------------------
@ -224,11 +423,10 @@ 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
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not yet published
mock_client = AsyncMock()
mock_client.head.return_value = mock_response
mock_client.get.return_value = _mock_response(text=index_html)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
@ -238,6 +436,7 @@ async def test_poll_fallback_when_no_db_data() -> None:
# Fallback: 2025Q4 → check 2026Q1
assert result["year"] == 2026
assert result["quarter"] == 1
assert result["available"] is False
# latest_loaded_year/quarter should be None (no DB data)
assert result["latest_loaded_year"] is None
assert result["latest_loaded_quarter"] is None