gendesign/backend/tests/services/scrapers/test_flat_plan_download.py
bot-backend b6e5919cf3
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 14m49s
feat(scrapers): extract + download DOM.РФ flat plan images (#2440)
domrf_kn_flat_plans было пусто (0 строк) — блокер для #299 (Potrace
production-wiring) и #300 (CubiCasa). domrf_catalog.py уже фетчит SSR-
страницы каталога квартир, где лежит планировка, но _TextCollector
сознательно дропал <img> как void-tag (#1608) — картинка никогда не
извлекалась.

parse_catalog_flat теперь возвращает plan_image_url: primary-стратегия
парсит __NEXT_DATA__ pageProps JSON (Next.js SSR-паттерн), fallback —
голый <img> по class-hint/соседству с текстом "Планировка". _TextCollector
получает аддитивный self.images-сбор ДО void-tag early-return — не трогает
существующий stack/buffer, регрессия по #1608 исключена (тест
test_img_capture_preserves_text_blocks).

download_plan_image_stub (NotImplementedError) заменён на рабочую
async-загрузку через существующий BrowserSession.download_binary
(те же cookies/TLS/throttle), Pillow для width/height, идемпотентна
(skip при уже существующем файле). Двухфазный upsert: URL пишется
первым, затем метаданные после скачивания — частичный прогресс
переживает падение загрузки.

Не заводит новый Celery beat — не подключено ни в один task (домрана
предпосылка issue #2440: scrape_one_flat/scrape_catalog_batch сейчас
нигде не вызываются). Ships inert до отдельного follow-up по wiring.

Refs #2440, #299, #300
2026-07-05 21:07:37 +05:00

136 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты для download_plan_image() из flat_plans.py (issue #2440).
Покрывает:
- успешное скачивание: файл на диске, width/height/size_bytes через Pillow.
- idempotency: повторный вызов не качает заново (download_binary не дёргается).
- ошибка скачивания → None, файл не создаётся.
- _safe_ods_filename / _ext_from_url helpers.
Использует фейковый BrowserSession с in-memory PNG (Pillow генерит 3x2 картинку) —
без сети и без реального Playwright.
"""
from __future__ import annotations
import io
from pathlib import Path
from typing import Any
import pytest
from PIL import Image
from app.services.scrapers.flat_plans import (
_ext_from_url,
_safe_ods_filename,
download_plan_image,
)
def _png_bytes(w: int = 3, h: int = 2) -> bytes:
buf = io.BytesIO()
Image.new("RGB", (w, h), (10, 20, 30)).save(buf, format="PNG")
return buf.getvalue()
class _FakeSession:
"""Минимальный BrowserSession-дублёр: download_binary отдаёт заданные байты."""
def __init__(self, payload: bytes | None, raise_exc: Exception | None = None) -> None:
self._payload = payload
self._raise = raise_exc
self.calls = 0
async def download_binary(self, url: str) -> bytes:
self.calls += 1
if self._raise is not None:
raise self._raise
assert self._payload is not None
return self._payload
# ── helpers ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"ods,expected",
[
("65136/1/1.4.3", "65136_1_1.4.3"),
("plain", "plain"),
("a//b", "a_b"),
("///", "plan"),
],
)
def test_safe_ods_filename(ods: str, expected: str) -> None:
assert _safe_ods_filename(ods) == expected
@pytest.mark.parametrize(
"url,expected",
[
("https://x/plan.png", ".png"),
("https://x/plan.JPG", ".jpg"),
("https://x/plan.jpeg", ".jpg"),
("https://x/api/ext/file/abc", ".png"), # no ext → default png
("https://x/plan.svg", ".svg"),
],
)
def test_ext_from_url(url: str, expected: str) -> None:
assert _ext_from_url(url) == expected
# ── download ──────────────────────────────────────────────────────────────────
def _read(path: str) -> bytes:
"""Sync file read — вынесено из async-теста, чтобы не тревожить ASYNC240."""
return Path(path).read_bytes()
def _exists(path: Path) -> bool:
return path.exists()
async def test_download_writes_file_and_dims(tmp_path: Path) -> None:
png = _png_bytes(3, 2)
sess: Any = _FakeSession(png)
meta = await download_plan_image(
sess, "https://x/api/ext/file/plan1.png", "65136/1/1.4.3", 65136, base_dir=tmp_path
)
assert meta is not None
assert meta["width_px"] == 3
assert meta["height_px"] == 2
assert meta["size_bytes"] == len(png)
assert meta["downloaded_at"] is not None
p = Path(meta["local_path"])
assert _read(str(p)) == png
# путь: {base}/{obj_id}/{safe_ods}.png
assert p.parent.name == "65136"
assert p.name == "65136_1_1.4.3.png"
async def test_download_idempotent_skips_second_fetch(tmp_path: Path) -> None:
png = _png_bytes(4, 4)
sess: Any = _FakeSession(png)
m1 = await download_plan_image(sess, "https://x/plan.png", "1/2/3", 42, base_dir=tmp_path)
assert m1 is not None
assert sess.calls == 1
# второй вызов: файл уже на диске → download_binary НЕ дёргается
m2 = await download_plan_image(sess, "https://x/plan.png", "1/2/3", 42, base_dir=tmp_path)
assert m2 is not None
assert sess.calls == 1 # не увеличилось
assert m2["local_path"] == m1["local_path"]
assert m2["size_bytes"] == len(png)
async def test_download_failure_returns_none(tmp_path: Path) -> None:
sess: Any = _FakeSession(None, raise_exc=RuntimeError("binary http 403"))
meta = await download_plan_image(sess, "https://x/plan.png", "9/9/9", 99, base_dir=tmp_path)
assert meta is None
# файл не создан
assert not _exists(tmp_path / "99")
async def test_download_empty_body_returns_none(tmp_path: Path) -> None:
sess: Any = _FakeSession(b"")
meta = await download_plan_image(sess, "https://x/plan.png", "9/9/9", 99, base_dir=tmp_path)
assert meta is None