"""Unit-тесты для DomClick scraper (#796). Тестируем: - Парсинг title-паттерна (rooms/area/floor) из текста карточки - Парсинг цены из отдельного DOM-элемента - Извлечение source_id из href - Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML """ from __future__ import annotations import asyncio import pytest from app.services.scrapers.domclick import ( DomClickScraper, _extract_area, _extract_floor, _extract_price_from_element, _extract_rooms, _extract_source_id, ) from app.services.scrapers.domclick_exceptions import DomClickBlockedError # ── Rooms / studio ─────────────────────────────────────────────────────────── @pytest.mark.parametrize( "text, expected", [ ("2-комн. квартира 53,5 м² 5/15 эт.", 2), ("1-к. квартира 32,1 м² 3/9 эт.", 1), ("3-комн. кв. 78 м² 10/12 эт.", 3), ("4-комн. квартира 95 м² 2/5 эт.", 4), ("студия 24 м² 1/10 эт.", 0), ("Студия 30,5 м² 8/22 эт.", 0), ("Нет комнат — просто текст", None), ], ) def test_extract_rooms(text: str, expected: int | None) -> None: assert _extract_rooms(text) == expected # ── Area ───────────────────────────────────────────────────────────────────── @pytest.mark.parametrize( "text, expected", [ ("2-комн. квартира 53,5 м² 5/15 эт.", 53.5), ("студия 24 м² 1/10 эт.", 24.0), ("78м2 на этаже", 78.0), ("площадь 101.7 м²", 101.7), ("Нет площади", None), ], ) def test_extract_area(text: str, expected: float | None) -> None: assert _extract_area(text) == expected # ── Floor ───────────────────────────────────────────────────────────────────── @pytest.mark.parametrize( "text, floor, total", [ ("2-комн. квартира 53,5 м² 5/15 эт.", 5, 15), ("студия 24 м² 1/10 эт.", 1, 10), ("12/24 эт.", 12, 24), ("нет этажа", None, None), ], ) def test_extract_floor(text: str, floor: int | None, total: int | None) -> None: assert _extract_floor(text) == (floor, total) # ── Price (per-element) ─────────────────────────────────────────────────────── # Цена парсится из отдельного DOM-элемента (не из суммарного card_text), # поэтому числа адреса не загрязняют парсинг цены. @pytest.mark.parametrize( "text, expected", [ ("5 200 000 ₽", 5_200_000), ("12 345 678 ₽", 12_345_678), ("3 990 000 руб.", 3_990_000), ("от 4 500 000 ₽", 4_500_000), ("без цены", None), ("0 ₽", None), ], ) def test_extract_price_from_element(text: str, expected: int | None) -> None: assert _extract_price_from_element(text) == expected # ── source_id from href ─────────────────────────────────────────────────────── @pytest.mark.parametrize( "href, expected", [ ("/card/sale__flat__2075671636", "2075671636"), ("https://domclick.ru/card/sale__flat__123456789", "123456789"), ("/card/sale__flat__1", "1"), ("/card/something_else", None), ("", None), ], ) def test_extract_source_id(href: str, expected: str | None) -> None: assert _extract_source_id(href) == expected # ── _parse_html -> list[ScrapedLot] ────────────────────────────────────────── # Цена в каждой карточке вынесена в отдельный с символом рубля, # чтобы не сливаться с адресным номером дома при конкатенации card_text. _MOCK_HTML = """\ 2-комн. квартира 53,5 м² 5/15 эт. ул. Малышева, 1 5 200 000 ₽ Студия 28 м² 3/10 эт. пр-т Ленина, 50 3 100 000 ₽ 1-комн. квартира 32 м² 2/9 эт. что-то другое 2-комн. квартира 53,5 м² 5/15 эт. 5 200 000 ₽ """ def test_parse_html_returns_lots() -> None: scraper = DomClickScraper() lots = scraper._parse_html(_MOCK_HTML) # Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован assert len(lots) == 2 def test_parse_html_card1_fields() -> None: scraper = DomClickScraper() lots = scraper._parse_html(_MOCK_HTML) card1 = next(lot for lot in lots if lot.source_id == "2075671636") assert card1.source == "domklik" assert card1.source_url == "https://domclick.ru/card/sale__flat__2075671636" assert card1.rooms == 2 assert card1.area_m2 == 53.5 assert card1.floor == 5 assert card1.total_floors == 15 assert card1.price_rub == 5_200_000 assert card1.lat is None assert card1.lon is None assert card1.listing_segment == "vtorichka" def test_parse_html_studio() -> None: scraper = DomClickScraper() lots = scraper._parse_html(_MOCK_HTML) studio = next(lot for lot in lots if lot.source_id == "1111111111") assert studio.rooms == 0 # студия assert studio.area_m2 == 28.0 assert studio.price_rub == 3_100_000 def test_parse_html_no_price_filtered() -> None: scraper = DomClickScraper() lots = scraper._parse_html(_MOCK_HTML) source_ids = {lot.source_id for lot in lots} assert "9999999999" not in source_ids def test_parse_html_dedup_same_href() -> None: """Дубликат href на одной странице должен давать одну карточку.""" scraper = DomClickScraper() lots = scraper._parse_html(_MOCK_HTML) ids_2075 = [lot for lot in lots if lot.source_id == "2075671636"] assert len(ids_2075) == 1 def test_scraper_source_name() -> None: scraper = DomClickScraper() assert scraper.name == "domklik" assert scraper.source == "domklik" assert scraper.base_url == "https://domclick.ru" # request_delay_sec установлен из get_scraper_delay (DB или fallback 8.0) assert scraper.request_delay_sec >= 0.0 def test_fetch_around_raises() -> None: scraper = DomClickScraper() with pytest.raises(NotImplementedError, match="geo-radius"): asyncio.run(scraper.fetch_around(56.8, 60.6)) def test_build_url_no_rooms() -> None: scraper = DomClickScraper() url = scraper._build_url(city_id=4, rooms=None, page=1) assert "city_id=4" in url assert "rooms" not in url assert "p=1" in url def test_build_url_with_rooms() -> None: scraper = DomClickScraper() url = scraper._build_url(city_id=4, rooms=2, page=3) assert "rooms=2" in url assert "p=3" in url def test_parse_failures_counter() -> None: """parse_failures счётчик начинается с 0.""" scraper = DomClickScraper() assert scraper.parse_failures == 0 def test_blocked_page_raises_domclick_blocked_error() -> None: """HTML с datadome в начале вызывает DomClickBlockedError.""" scraper = DomClickScraper() blocked_html = ( "datadome protectionblocked" ) with pytest.raises(DomClickBlockedError): scraper._parse_html(blocked_html)