"""Unit tests for YandexDetailScraper — Product JSON-LD + DOM detail parser. All tests run against hand-crafted HTML fixtures. No live network access. """ from __future__ import annotations import json import pytest from app.services.scrapers.yandex_detail import ( DetailEnrichment, MetroStation, YandexDetailScraper, _extract_relative_date, _find_section_text, _parse_metro_stations, _parse_title, ) # --------------------------------------------------------------------------- # Helpers to build fixture HTML # --------------------------------------------------------------------------- _BASE_OFFER_URL = "https://realty.yandex.ru/offer/7812345000001/" _PRODUCT_LD = { "@type": "Product", "name": "3-комнатная квартира 85,5 м² на 12 этаж из 24", "image": [ "https://avatars.mds.yandex.net/get-realty/photo1/main", "https://avatars.mds.yandex.net/get-realty/photo2/main", "https://avatars.mds.yandex.net/get-realty/photo3/main", ], "offers": { "@type": "Offer", "price": 9850000, "priceCurrency": "RUB", }, } def _make_html( *, product_ld: dict | None = _PRODUCT_LD, h1: str = "3-комнатная квартира 85,5 м², 12 этаж из 24", summary_text: str = ( "Россия, Свердловская область, Екатеринбург, улица Малышева, д. 5" " • 115 233 ₽ за м²" " • свободная продажа" " • 342 просмотра" " • опубликовано 10 апреля 2026" ), author_block: str = "", description_text: str = "Продаётся просторная кирпичная квартира 1995 года постройки.", location_text: str = "Уральская 11 мин. Динамо 16 мин.", nb_href: str | None = "/ekaterinburg/kupit/novostrojka/tatlin-1592987/", extra_head: str = "", ) -> str: ld_block = "" if product_ld is not None: ld_block = ( f'' ) nb_link = "" if nb_href: nb_link = f'ЖК Татлин' return f"""
{extra_head}{ld_block}{description_text}
{location_text}
{nb_link} """ # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- FULL_HTML = _make_html() SCRAPER = YandexDetailScraper() # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestFullDetailFixture: """Happy path — all fields populated.""" def test_parse_full_detail_fixture(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert isinstance(result, DetailEnrichment) assert result.offer_id == "7812345000001" assert result.source_url == _BASE_OFFER_URL def test_price_from_json_ld(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.price_rub == 9_850_000 def test_title_and_room_parsing(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.rooms == 3 assert result.area_m2 == pytest.approx(85.5) assert result.floor == 12 assert result.total_floors == 24 def test_views_and_publish_date(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None from datetime import date assert result.views_total == 342 assert result.publish_date == date(2026, 4, 10) def test_sale_type_free(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.sale_type_text == "свободная продажа" def test_price_per_m2_from_summary(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.price_per_m2 == 115_233 def test_address_extraction(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.address is not None assert "Малышева" in result.address def test_description_section(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.description is not None assert "кирпичная" in result.description def test_nlp_house_type_from_description(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.house_type_nlp == "brick" def test_year_built_hint_nlp(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.year_built_hint == 1995 def test_raw_payload_present(self) -> None: result = SCRAPER.parse(FULL_HTML, offer_url=_BASE_OFFER_URL) assert result is not None assert result.raw_payload is not None assert "summary_text" in result.raw_payload assert "photo_count" in result.raw_payload class TestNoProductLD: """Missing JSON-LD — scraper falls back to summary / DOM only.""" def test_parse_no_product_ld_uses_summary_fallbacks(self) -> None: html = _make_html(product_ld=None) result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) assert result is not None # price_rub is None when no JSON-LD and no ₽ total in summary assert result.price_rub is None # price_per_m2 still extracted from "₽ за м²" in summary assert result.price_per_m2 == 115_233 # photos array empty when no JSON-LD image[] assert result.photo_urls == [] class TestStudio: def test_parse_studio_rooms_zero(self) -> None: html = _make_html( product_ld=None, h1="Студия 28 м², 2 этаж из 9", summary_text="альтернативная • 50 просмотров", ) result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) assert result is not None assert result.rooms == 0 assert result.area_m2 == pytest.approx(28.0) assert result.floor == 2 assert result.total_floors == 9 assert result.sale_type_text == "альтернативная" class TestNoAgencySection: def test_parse_no_agency_section(self) -> None: html = _make_html(author_block="") # no OfferCardAuthorInfo result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) assert result is not None assert result.agency_name is None assert result.agency_founded_year is None assert result.agency_objects_count is None assert result.seller_name is None class TestAgencyBlock: def test_agency_fields_parsed(self) -> None: # Note: selectolax .text(strip=True) concatenates text nodes without spaces. # "Год основания 1998" + "150 объектов" becomes "Год основания 1998150 объектов". # RE_AGENCY_OBJECTS = r"(\d+)\s+объект" would then match "1998150 объект". # To avoid this ambiguity the fixture uses а non-digit separator between spans. author_html = """Год основания 1998. Продали 150 объектов.
текст
") assert _find_section_text(tree, "Описание") is None def test_metro_walk_min_from_nlp(self) -> None: """metro_walk_min extracted from description via RE_METRO_WALK.""" html = _make_html( description_text="Квартира в 7 минут неспешной прогулки от метро.", location_text="", ) result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) assert result is not None assert result.metro_walk_min == 7