fix(tradein): repoint rosreestr_poll to /data-sets/ + verify Content-Type
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s

The СДЕЛКИ open-data portal moved off /opendata/ (Bitrix redesign); the old
flat dataset URL now returns HTTP 200 with Content-Type text/html (soft-404)
instead of 404, so check_new_quarter_available() falsely reported every
quarter as available. /opendata/f.json also no longer serves JSON.

Rewrite the check to parse the new https://rosreestr.gov.ru/data-sets/ Apache
autoindex: decode each folder/file href as cp1251 percent-encoding (portal
does not use utf-8), locate the target quarter folder and dataset_СДЕЛКИ file,
then HEAD it and require Content-Type: application/zip AND Content-Length
> 100 KB before treating it as available — a bare 200 is no longer sufficient.
Also send a browser User-Agent and verify=False (RU TLS root CA, same pattern
as sber_index.py) since rosreestr.gov.ru 403s plain httpx requests.

Public signatures and the poll_rosreestr_new_quarter() return dict are
unchanged. rosreestr_dataset_url() now builds a best-effort cp1251-encoded
link into /data-sets/ (verified byte-for-byte against the live portal) for
the alert message; detection itself never depends on this guess.
This commit is contained in:
bot-backend 2026-07-12 16:16:34 +03:00
parent d22e0e00ae
commit eac5a49a3a
2 changed files with 462 additions and 97 deletions

View file

@ -5,31 +5,62 @@
открытых данных Росреестра. открытых данных Росреестра.
- ALERT: залогировать actionable-сообщение, если квартал доступен. - ALERT: залогировать actionable-сообщение, если квартал доступен.
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders это ручной - НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders это ручной
ops-шаг. После алерта оператор запускает: ops-шаг. После алерта оператор запускает (оба скрипта живут вне этого
репозитория при смене базового пути их тоже нужно поправить вручную):
data/sql/02_load_all_quarters.sh data/sql/02_load_all_quarters.sh
tradein-mvp/deploy/import-rosreestr.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, который чинит этот модуль.
Файлы именуются по шаблону: АКТУАЛЬНОЕ (2026-07) расположение датасетов открытый Apache autoindex без
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip авторизации:
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 живёт по адресу: Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов) расставляют руками, возможны отличия в пробелах/пунктуации между кварталами)
поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый
percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")),
затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном
имени. См. check_new_quarter_available().
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал. КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный
Формат ссылки: файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200
недостаточен.
При сетевой ошибке / HTTP 5xx логируем warning, возвращаем available=False. rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет
Статус 404 квартал не опубликован available=False (штатный случай до начала августа). доверия в стандартном 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 from __future__ import annotations
import logging import logging
import re
from typing import Any from typing import Any
from urllib.parse import quote, unquote, urljoin
import httpx import httpx
from sqlalchemy import text from sqlalchemy import text
@ -37,16 +68,24 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала). # Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля).
# Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru. _DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/"
_ROSREESTR_DATASET_URL_TEMPLATE = (
"https://rosreestr.gov.ru/opendata/" # Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403.
"dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip" # Тот же паттерн, что 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 _HTTP_TIMEOUT = 15.0
_HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE)
def _next_quarter(year: int, quarter: int) -> tuple[int, int]: def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
"""Вернуть (year, quarter) для следующего квартала. """Вернуть (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: 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( async def check_new_quarter_available(
@ -129,48 +225,109 @@ async def check_new_quarter_available(
year: int, year: int,
quarter: int, quarter: int,
) -> bool: ) -> 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). Отсутствие папки/файла квартала available=False (нормально до публикации).
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке. Сетевая ошибка / таймаут / неожиданное исключение warning, available=False.
Никогда не поднимает исключения в вызывающий код (scheduler-safe). Никогда не поднимает исключения в вызывающий код (scheduler-safe).
""" """
url = rosreestr_dataset_url(year, quarter)
try: try:
resp = await client.head(url, follow_redirects=True) index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
if resp.status_code == 200: if index_resp.status_code != 200:
logger.info( logger.warning(
"rosreestr_poll: Q%d %d available at %s (HTTP %d)", "rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
quarter, index_resp.status_code,
year, _DATA_SETS_BASE_URL,
url,
resp.status_code,
)
return True
if resp.status_code == 404:
logger.info(
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
quarter, quarter,
year, year,
) )
return False return False
# Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем
logger.warning( folder_href = _find_quarter_folder_href(index_resp.text, year, quarter)
"rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable", if folder_href is None:
resp.status_code, 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, quarter,
year, year,
url, file_url,
file_resp.status_code,
content_type,
content_length,
) )
return False return False
except httpx.TimeoutException: except httpx.TimeoutException:
logger.warning( 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, quarter,
year, year,
url, _DATA_SETS_BASE_URL,
) )
return False return False
except httpx.RequestError as exc: 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). 1. Найти MAX(deal_date) WHERE source='rosreestr' (loaded_year, loaded_quarter).
2. Вычислить next = _next_quarter(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. 4. Если доступен логировать actionable INFO с инструкцией по запуску ingest.
5. Вернуть dict с результатом. 5. Вернуть dict с результатом.
@ -230,18 +387,27 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
loaded_year, loaded_year,
) )
# 3. Проверка наличия # 3. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store;
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: # браузерный 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) available = await check_new_quarter_available(client, next_year, next_quarter)
# 4. Алерт если доступен # 4. Алерт если доступен
if available: if available:
logger.info( logger.info(
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. " "rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d (%s). "
"Run ingest to load: " "Run ingest to load: data/sql/02_load_all_quarters.sh + "
"data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.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_quarter,
next_year, next_year,
rosreestr_dataset_url(next_year, next_quarter),
_DATA_SETS_BASE_URL,
) )
return { return {

View file

@ -3,10 +3,17 @@
Coverage: Coverage:
(a) _next_quarter: boundary cases (Q4 Q1 of next year, mid-year). (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. (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). (c) rosreestr_dataset_url: cp1251 percent-encoding matches the real portal URL.
(d) poll_rosreestr_new_quarter available=False path. (d) check_new_quarter_available new /data-sets/ autoindex flow:
(e) Network error available=False (never raises into caller). - real application/zip + large Content-Length True
(f) No DB data fallback baseline 2025Q4, checks 2026Q1. - 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 from __future__ import annotations
@ -15,6 +22,7 @@ import os
import sys import sys
from datetime import date from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import quote
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") 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, 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 # (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) 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: def test_rosreestr_dataset_url_contains_year_quarter() -> None:
url = rosreestr_dataset_url(2026, 2) url = rosreestr_dataset_url(2026, 2)
assert "2026" in url 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 @pytest.mark.asyncio
async def test_poll_available_true_returns_correct_next_quarter() -> None: async def test_check_available_true_for_real_zip() -> None:
"""If latest loaded is Q1 2026 and head returns 200, returns available=True for Q2 2026.""" """Folder found, file found, HEAD returns application/zip + large Content-Length → True."""
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026 index_html = _autoindex_html("1 квартал 2026г.", "4 квартал 2025г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1, _KADASTR_FILE_2026_Q1)
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock() 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.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False) 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): with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type] 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 assert result["latest_loaded_quarter"] == 1
# ---------------------------------------------------------------------------
# (d) poll_rosreestr_new_quarter — available=False (404)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_poll_available_false_on_404() -> None: async def test_poll_available_false_when_folder_not_published() -> None:
"""404 from server → available=False.""" """Quarter folder not yet published (autoindex has no matching entry) → available=False."""
db = _FakeDB(date(2026, 3, 15)) # Q1 2026 db = _FakeDB(date(2026, 3, 15)) # Q1 2026 → next = Q2 2026
mock_response = MagicMock() index_html = _autoindex_html("1 квартал 2026г.") # Q2 2026 missing
mock_response.status_code = 404
mock_client = AsyncMock() 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.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
@ -178,11 +394,6 @@ async def test_poll_available_false_on_404() -> None:
assert result["quarter"] == 2 assert result["quarter"] == 2
# ---------------------------------------------------------------------------
# (e) Network error → available=False (never raises into caller)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_poll_network_error_returns_false_no_raise() -> None: async def test_poll_network_error_returns_false_no_raise() -> None:
"""Network error → available=False, no exception propagated.""" """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 db = _FakeDB(date(2026, 1, 20)) # Q1 2026
mock_client = AsyncMock() 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.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False) 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 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 # (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.""" """No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1."""
db = _FakeDB(None) db = _FakeDB(None)
mock_response = MagicMock() index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not yet published
mock_response.status_code = 404 # not yet available
mock_client = AsyncMock() 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.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False) 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 # Fallback: 2025Q4 → check 2026Q1
assert result["year"] == 2026 assert result["year"] == 2026
assert result["quarter"] == 1 assert result["quarter"] == 1
assert result["available"] is False
# latest_loaded_year/quarter should be None (no DB data) # latest_loaded_year/quarter should be None (no DB data)
assert result["latest_loaded_year"] is None assert result["latest_loaded_year"] is None
assert result["latest_loaded_quarter"] is None assert result["latest_loaded_quarter"] is None