"""Tests for cian_detail.py — Stage 5 of CianScraper v1.""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock import pytest from app.services.scrapers.cian_detail import ( DetailEnrichment, _extract_agent_profile, _extract_price_changes, _extract_repair_state, _parse_views, fetch_detail, ) # ── _parse_views ────────────────────────────────────────────────────────────── def test_parse_views_space_formatted(): assert _parse_views("1 234") == 1234 def test_parse_views_plain(): assert _parse_views("567") == 567 def test_parse_views_none(): assert _parse_views(None) is None def test_parse_views_invalid(): assert _parse_views("invalid") is None def test_parse_views_comma_formatted(): assert _parse_views("1,234") == 1234 # ── _extract_repair_state — структурное поле в приоритете, fallback на текст (#622) ── def test_repair_state_structured_wins_over_description(): """repairType присутствует → используется он, описание игнорируется.""" offer = { "repairType": "cosmetic", # → standard "description": "Шикарный дизайнерский ремонт", # инференс дал бы excellent } assert _extract_repair_state(offer) == "standard" def test_repair_state_falls_back_to_description_when_structured_missing(): """repairType отсутствует → инференс из описания.""" offer = { "repairType": None, "description": "Сделан свежий евроремонт, заезжай и живи", } assert _extract_repair_state(offer) == "good" def test_repair_state_none_when_both_missing(): """Нет structured и нет сигнала в тексте → None (не фабрикуем).""" offer = {"description": "Светлая квартира рядом с парком"} assert _extract_repair_state(offer) is None # ── _extract_price_changes ──────────────────────────────────────────────────── def test_extract_price_changes_normalizes(): offer = { "priceChanges": [ {"changeTime": "2026-04-01T00:00:00Z", "price": 5000000, "diffPercent": -2.5}, {"changeTime": "2026-04-10T00:00:00Z", "price": 4900000, "diffPercent": -2.0}, ] } result = _extract_price_changes(offer, {}) assert len(result) == 2 assert result[0]["change_time"] == "2026-04-01T00:00:00Z" assert result[0]["price_rub"] == 5000000 assert result[0]["diff_percent"] == -2.5 def test_extract_price_changes_handles_empty_offer(): assert _extract_price_changes({}, {}) == [] def test_extract_price_changes_handles_none_value(): assert _extract_price_changes({"priceChanges": None}, {}) == [] def test_extract_price_changes_falls_back_to_state(): state = { "priceChanges": [ {"changeTime": "2026-03-01T00:00:00Z", "price": 6000000, "diffPercent": 0}, ] } result = _extract_price_changes({}, state) assert len(result) == 1 assert result[0]["price_rub"] == 6000000 def test_extract_price_changes_skips_non_dict_entries(): offer = {"priceChanges": [{"changeTime": "2026-04-01T00:00:00Z", "price": 5000000}, "bad"]} result = _extract_price_changes(offer, {}) assert len(result) == 1 # ── _extract_agent_profile ──────────────────────────────────────────────────── def test_extract_agent_profile_from_agent_key(): offer = { "agent": { "id": 12345, "companyName": "Новосёл", "isPro": True, "skills": [{"id": 1, "name": "Продажа"}], "accountType": "specialist", } } profile = _extract_agent_profile(offer) assert profile is not None assert profile["ext_agent_id"] == 12345 assert profile["company_name"] == "Новосёл" assert profile["is_pro"] is True assert profile["account_type"] == "specialist" def test_extract_agent_profile_from_user_key(): offer = { "user": { "cianUserId": 99999, "companyName": "Агентство Икс", "isPro": False, "accountType": "agency", } } profile = _extract_agent_profile(offer) assert profile is not None assert profile["ext_agent_id"] == 99999 assert profile["company_name"] == "Агентство Икс" def test_extract_agent_profile_returns_none_when_no_agent(): assert _extract_agent_profile({}) is None assert _extract_agent_profile({"agent": {}, "user": {}}) is None # ── DetailEnrichment defaults ───────────────────────────────────────────────── def test_dataclass_defaults(): enrich = DetailEnrichment() assert enrich.cian_id is None assert enrich.price_changes == [] assert enrich.raw_sister_states == {} assert enrich.bti_data is None assert enrich.agent_profile is None assert enrich.newbuilding_data is None # ── fetch_detail integration (mocked) ──────────────────────────────────────── @pytest.mark.asyncio async def test_fetch_detail_returns_none_on_no_state(monkeypatch): """If state extraction returns None → fetch_detail returns None gracefully.""" monkeypatch.setattr( "app.services.scrapers.cian_detail.extract_state", lambda html, mfe, key: None, ) response = MagicMock() response.status_code = 200 response.text = "" session = MagicMock() session.get = AsyncMock(return_value=response) session.close = AsyncMock() result = await fetch_detail("https://ekb.cian.ru/sale/flat/123/", session=session) assert result is None @pytest.mark.asyncio async def test_fetch_detail_returns_none_on_non_200(monkeypatch): """Non-200 HTTP response → returns None, no exception raised.""" response = MagicMock() response.status_code = 403 response.text = "" session = MagicMock() session.get = AsyncMock(return_value=response) session.close = AsyncMock() result = await fetch_detail("https://ekb.cian.ru/sale/flat/456/", session=session) assert result is None @pytest.mark.asyncio async def test_fetch_detail_extracts_core_fields(monkeypatch): """Verify core fields are extracted correctly from mocked state.""" fake_state = { "offerData": { "offer": { "cianId": 327830237, "windowsViewType": "yardAndStreet", "separateWcsCount": 1, "combinedWcsCount": 0, "ceilingHeight": 2.65, "repairType": "cosmetic", "priceChanges": [ { "changeTime": "2026-04-01T00:00:00Z", "price": 5500000, "diffPercent": -1.8, } ], "agent": { "id": 7654321, "companyName": "Первичка Плюс", "isPro": True, "skills": [], "accountType": "specialist", }, }, "stats": { "totalViewsFormattedString": "1 042", "todayViewsFormattedString": "7", }, } } monkeypatch.setattr( "app.services.scrapers.cian_detail.extract_state", lambda html, mfe, key: fake_state if key == "defaultState" else None, ) monkeypatch.setattr( "app.services.scrapers.cian_detail.extract_all_states", lambda html: {}, ) response = MagicMock() response.status_code = 200 response.text = "" session = MagicMock() session.get = AsyncMock(return_value=response) session.close = AsyncMock() result = await fetch_detail("https://ekb.cian.ru/sale/flat/327830237/", session=session) assert result is not None assert result.cian_id == 327830237 assert result.windows_view_type == "yardAndStreet" assert result.separate_wcs_count == 1 assert result.combined_wcs_count == 0 assert result.ceiling_height == 2.65 assert result.repair_type == "cosmetic" assert result.views_total == 1042 assert result.views_today == 7 assert len(result.price_changes) == 1 assert result.price_changes[0]["price_rub"] == 5500000 assert result.agent_profile is not None assert result.agent_profile["ext_agent_id"] == 7654321 assert result.agent_profile["company_name"] == "Первичка Плюс" @pytest.mark.asyncio async def test_fetch_detail_extracts_bti_from_sister_states(monkeypatch): """BTI sister state is captured into bti_data.""" fake_state = { "offerData": { "offer": { "cianId": 111111, } } } fake_all_states = { "frontend-offer-card": { "bti": { "houseData": { "seriesName": "П-44Т", "floorsCount": 17, "flatCount": 120, } } } } monkeypatch.setattr( "app.services.scrapers.cian_detail.extract_state", lambda html, mfe, key: fake_state if key == "defaultState" else None, ) monkeypatch.setattr( "app.services.scrapers.cian_detail.extract_all_states", lambda html: fake_all_states, ) response = MagicMock() response.status_code = 200 response.text = "" session = MagicMock() session.get = AsyncMock(return_value=response) session.close = AsyncMock() result = await fetch_detail("https://ekb.cian.ru/sale/flat/111111/", session=session) assert result is not None assert result.bti_data is not None assert result.bti_data["seriesName"] == "П-44Т" assert "bti" in result.raw_sister_states @pytest.mark.asyncio async def test_fetch_detail_extracts_newbuilding_link(monkeypatch): """Newbuilding reference is captured from offer.newbuilding.""" fake_state = { "offerData": { "offer": { "cianId": 222222, "newbuilding": { "id": 54016, "name": "Екатерининский парк", "url": "https://zhk-ekaterininskiy-park-ekb-i.cian.ru/", }, } } } monkeypatch.setattr( "app.services.scrapers.cian_detail.extract_state", lambda html, mfe, key: fake_state if key == "defaultState" else None, ) monkeypatch.setattr( "app.services.scrapers.cian_detail.extract_all_states", lambda html: {}, ) response = MagicMock() response.status_code = 200 response.text = "" session = MagicMock() session.get = AsyncMock(return_value=response) session.close = AsyncMock() result = await fetch_detail("https://ekb.cian.ru/sale/flat/222222/", session=session) assert result is not None assert result.newbuilding_data is not None assert result.newbuilding_data["id"] == 54016