diff --git a/tradein-mvp/backend/app/services/rosreestr_poll.py b/tradein-mvp/backend/app/services/rosreestr_poll.py index de471bc8..1ef1d8df 100644 --- a/tradein-mvp/backend/app/services/rosreestr_poll.py +++ b/tradein-mvp/backend/app/services/rosreestr_poll.py @@ -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 { diff --git a/tradein-mvp/backend/tests/test_rosreestr_poll.py b/tradein-mvp/backend/tests/test_rosreestr_poll.py index 21083e6f..8025116e 100644 --- a/tradein-mvp/backend/tests/test_rosreestr_poll.py +++ b/tradein-mvp/backend/tests/test_rosreestr_poll.py @@ -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 = ['../'] + for name in folder_names: + href = _cp1251_href(name) + "/" + links.append(f'{name}/') + return "
" + "\n".join(links) + "
" + + +def _folder_html(*file_names: str) -> str: + """Build a fake Apache-autoindex-style HTML listing for a quarter folder.""" + links = ['../'] + for name in file_names: + href = _cp1251_href(name) + links.append(f'{name}') + return "
" + "\n".join(links) + "
" + + +_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