fix(tradein): repoint rosreestr_poll to /data-sets/ + verify Content-Type (СДЕЛКИ dataset moved off /opendata) #2477
2 changed files with 462 additions and 97 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue