"""Тесты для 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