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
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
136 lines
4.7 KiB
Python
136 lines
4.7 KiB
Python
"""Тесты для 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
|