"""Offline smoke tests для avito_imv модуля. Без сети — только cache_key вычисление, парсинг заголовков, unix→date, dataclass конструирование. """ import json from datetime import date from pathlib import Path import pytest from app.services.scrapers.avito_imv import ( IMVAddressNotFoundError, IMVEvaluation, IMVGeo, IMVPlacementHistoryItem, _parse_geo_position, _parse_placement_history, _parse_placement_item, _parse_price, _parse_suggestion, _parse_suggestions, _parse_title, _unix_to_date, compute_imv_cache_key, ) _FIXTURES = Path(__file__).parent / "fixtures" def _load_fixture(name: str) -> dict: with (_FIXTURES / name).open(encoding="utf-8") as f: return json.load(f) # --------------------------------------------------------------------------- # cache_key # --------------------------------------------------------------------------- def test_cache_key_deterministic(): k1 = compute_imv_cache_key( "ЕКБ ул. Чайковского, 66А", 2, 42.0, 4, 5, "panel", "cosmetic", True, False ) k2 = compute_imv_cache_key( "ЕКБ ул. Чайковского, 66А", 2, 42.0, 4, 5, "panel", "cosmetic", True, False ) assert k1 == k2 assert len(k1) == 64 # sha256 hex def test_cache_key_differs_by_rooms(): k1 = compute_imv_cache_key( "ЕКБ ул. Чайковского, 66А", 2, 42.0, 4, 5, "panel", "cosmetic", True, False ) k3 = compute_imv_cache_key( "ЕКБ ул. Чайковского, 66А", 3, 42.0, 4, 5, "panel", "cosmetic", True, False ) assert k1 != k3 def test_cache_key_differs_by_balcony(): k_with = compute_imv_cache_key( "ул. Ленина, 1", 1, 30.0, 1, 9, "brick", "euro", True, False ) k_without = compute_imv_cache_key( "ул. Ленина, 1", 1, 30.0, 1, 9, "brick", "euro", False, False ) assert k_with != k_without def test_cache_key_differs_by_address(): k1 = compute_imv_cache_key("ул. А, 1", 2, 42.0, 4, 5, "panel", "cosmetic", True, False) k2 = compute_imv_cache_key("ул. Б, 2", 2, 42.0, 4, 5, "panel", "cosmetic", True, False) assert k1 != k2 # --------------------------------------------------------------------------- # _unix_to_date # --------------------------------------------------------------------------- def test_unix_date_none(): assert _unix_to_date(None) is None def test_unix_date_zero(): assert _unix_to_date(0) is None def test_unix_date_positive(): result = _unix_to_date(1715000000) assert isinstance(result, date) # 1715000000 примерно 2024-05-06 UTC+5 (Екатеринбург) assert result.year == 2024 assert result.month in (5, 6) # зависит от локали def test_unix_date_float(): result = _unix_to_date(1697000000.0) assert isinstance(result, date) assert result.year == 2023 # --------------------------------------------------------------------------- # _parse_title # --------------------------------------------------------------------------- def test_parse_title_full(): parsed = _parse_title("2-к. квартира, 42 м², 4/5 эт.") assert parsed["rooms"] == 2 assert parsed["area_m2"] == 42.0 assert parsed["floor"] == 4 assert parsed["total_floors"] == 5 def test_parse_title_comma_decimal(): parsed = _parse_title("1-к. квартира, 27,1 м², 2/5 эт.") assert parsed["rooms"] == 1 assert parsed["area_m2"] == pytest.approx(27.1) assert parsed["floor"] == 2 assert parsed["total_floors"] == 5 def test_parse_title_none(): parsed = _parse_title(None) assert parsed["rooms"] is None assert parsed["area_m2"] is None def test_parse_title_no_match(): parsed = _parse_title("студия, 20 м²") assert parsed["rooms"] is None # --------------------------------------------------------------------------- # _parse_placement_item # --------------------------------------------------------------------------- def test_parse_placement_item_full(): raw = { "id": 9876543210, "title": "2-к. квартира, 42 м², 4/5 эт.", "startPrice": 6500000, "startPriceDate": 1697000000, "lastPrice": 6290000, "lastPriceDate": 1715000000, "removedDate": 1715500000, "exposure": 210, "itemImage": {"url": "https://avito.ru/img.jpg"}, # должен быть отфильтрован } item = _parse_placement_item(raw) assert item.ext_item_id == "9876543210" assert item.title == "2-к. квартира, 42 м², 4/5 эт." assert item.rooms == 2 assert item.area_m2 == 42.0 assert item.floor == 4 assert item.total_floors == 5 assert item.start_price == 6500000 assert isinstance(item.start_price_date, date) assert item.last_price == 6290000 assert isinstance(item.last_price_date, date) assert isinstance(item.removed_date, date) assert item.exposure_days == 210 # itemImage должен быть отфильтрован из raw_payload assert item.raw_payload is not None assert "itemImage" not in item.raw_payload def test_parse_placement_item_no_removed_date(): raw = { "id": 111, "title": "1-к. квартира, 30 м², 2/9 эт.", "startPrice": 3000000, "startPriceDate": 1697000000, "lastPrice": 2900000, "lastPriceDate": 1715000000, "exposure": 50, } item = _parse_placement_item(raw) assert item.removed_date is None assert item.ext_item_id == "111" # --------------------------------------------------------------------------- # _parse_suggestion # --------------------------------------------------------------------------- def test_parse_suggestion_full(): raw = { "id": 7986882804, "title": "2-к. квартира, 42 м², 4/5 эт.", "address": "Авиационная улица, 63к2, Екатеринбург", "price": 6300000, "exposure": 14, "publishDate": 1716000000, "itemLink": "/ekaterinburg/kvartiry/some-path", "metro": {"name": "Чкаловская", "distance": "2,7 км", "colors": ["#cf2734"]}, "hasGoodPriceBadge": False, "imageLink": "https://avito.ru/img.jpg", # должен быть отфильтрован } sugg = _parse_suggestion(raw) assert sugg.ext_item_id == "7986882804" assert sugg.price_rub == 6300000 assert sugg.metro_name == "Чкаловская" assert sugg.metro_distance == "2,7 км" assert sugg.metro_color == "#cf2734" assert sugg.item_url == "/ekaterinburg/kvartiry/some-path" assert sugg.has_good_price_badge is False assert isinstance(sugg.publish_date, date) # imageLink должен быть отфильтрован assert sugg.raw_payload is not None assert "imageLink" not in sugg.raw_payload def test_parse_suggestion_no_metro(): raw = { "id": 999, "title": "1-к. квартира, 25 м², 3/5 эт.", "price": 4000000, "exposure": 5, "publishDate": 1716000000, "itemLink": "/path", } sugg = _parse_suggestion(raw) assert sugg.metro_name is None assert sugg.metro_color is None # --------------------------------------------------------------------------- # IMVEvaluation dataclass конструирование # --------------------------------------------------------------------------- def test_dataclass_minimal_construct(): e = IMVEvaluation( cache_key="a" * 64, address="X", rooms=2, area_m2=42.0, floor=4, floor_at_home=5, house_type="panel", renovation_type="cosmetic", has_balcony=True, has_loggia=False, geo=IMVGeo(geo_hash="JWT.TOKEN.HERE"), recommended_price=6_290_000, lower_price=6_100_000, higher_price=6_600_000, ) assert e.recommended_price == 6_290_000 assert e.lower_price == 6_100_000 assert e.higher_price == 6_600_000 assert e.geo.geo_hash == "JWT.TOKEN.HERE" assert e.placement_history == [] assert e.suggestions == [] assert e.market_count is None assert e.raw_response is None def test_dataclass_with_history(): item = IMVPlacementHistoryItem( ext_item_id="123", title="2-к. квартира, 42 м², 4/5 эт.", rooms=2, area_m2=42.0, floor=4, total_floors=5, start_price=6500000, last_price=6290000, removed_date=date(2024, 5, 12), exposure_days=210, ) e = IMVEvaluation( cache_key="b" * 64, address="Y", rooms=2, area_m2=42.0, floor=4, floor_at_home=5, house_type="panel", renovation_type="euro", has_balcony=False, has_loggia=True, geo=IMVGeo(geo_hash="JWT2"), recommended_price=6_000_000, lower_price=5_800_000, higher_price=6_300_000, market_count=800, placement_history=[item], ) assert len(e.placement_history) == 1 assert e.placement_history[0].removed_date == date(2024, 5, 12) assert e.market_count == 800 # --------------------------------------------------------------------------- # _parse_geo_position (step B) — driven by live-captured fixture (issue #565) # --------------------------------------------------------------------------- def test_parse_geo_position_from_fixture(): """Real /js/v2/geo/position response (ЕКБ, ул. Малышева 125) → IMVGeo.""" data_b = _load_fixture("avito_imv_geo_position.json") geo = _parse_geo_position(data_b, lat=56.840872, lon=60.654077) assert geo.geo_hash # non-empty JWT assert geo.geo_hash.startswith("eyJ") # base64url JWT header assert geo.geo_hash.count(".") == 2 # header.payload.signature assert geo.avito_address_id == 22319477 assert geo.avito_location_id == 654070 assert geo.avito_district_id == 789 assert geo.lat == pytest.approx(56.840872) assert geo.lon == pytest.approx(60.654077) def test_parse_geo_position_ekb_has_no_metro(): """ЕКБ-окраина не отдаёт metroId — должен быть None, не падать.""" data_b = _load_fixture("avito_imv_geo_position.json") geo = _parse_geo_position(data_b, lat=56.84, lon=60.65) assert geo.avito_metro_id is None def test_parse_geo_position_missing_hash_raises(): """Пустой geoFieldsHash → IMVAddressNotFoundError (graceful skip в estimator).""" with pytest.raises(IMVAddressNotFoundError): _parse_geo_position( {"address": "X", "errorText": "not found", "geoFieldsHash": ""}, lat=1.0, lon=2.0, ) # --------------------------------------------------------------------------- # _parse_price / placement / suggestions (step C) — live-captured fixture # --------------------------------------------------------------------------- def test_parse_price_from_fixture(): """Real realty-imv/get-data price block populates all three prices + count.""" data = _load_fixture("avito_imv_getdata.json") recommended, lower, higher, count = _parse_price(data) assert recommended == 7370000 assert lower == 7148900 assert higher == 7738500 assert count == 800 assert lower < recommended < higher def test_parse_price_missing_block_defaults_zero(): """Нет price блока (degenerate ответ) → нули, не KeyError.""" recommended, lower, higher, count = _parse_price({}) assert (recommended, lower, higher) == (0, 0, 0) assert count is None def test_parse_placement_history_from_fixture(): """placementHistory.items парсятся в IMVPlacementHistoryItem с removed_date.""" data = _load_fixture("avito_imv_getdata.json") items = _parse_placement_history(data) assert len(items) >= 1 first = items[0] assert first.ext_item_id # str(id) assert first.start_price and first.last_price assert isinstance(first.start_price_date, date) assert isinstance(first.removed_date, date) # raw_payload очищен от тяжёлого itemImage assert first.raw_payload is not None assert "itemImage" not in first.raw_payload def test_parse_placement_history_absent_block_returns_empty(): """placementHistory отсутствует (как у проспект Ленина) → [] без падения.""" data = _load_fixture("avito_imv_getdata.json") data.pop("placementHistory", None) assert _parse_placement_history(data) == [] def test_parse_suggestions_from_fixture(): """suggestions.items парсятся + searchSimilarUrl извлекается.""" data = _load_fixture("avito_imv_getdata.json") suggestions, search_url = _parse_suggestions(data) assert len(suggestions) >= 1 assert all(s.ext_item_id for s in suggestions) assert all(s.price_rub for s in suggestions) # raw_payload очищен от imageLink assert "imageLink" not in (suggestions[0].raw_payload or {}) assert search_url is not None assert search_url.startswith("/") def test_fixtures_feed_full_evaluation_shape(): """Сквозной оффлайн-тест: fixtures B+C → заполненный IMVEvaluation. Зеркалит то, что _geocode + _imv_evaluate собирают из живого ответа, без сети. Регрессионный guard для контракта step B/C (issue #565). """ data_b = _load_fixture("avito_imv_geo_position.json") data_c = _load_fixture("avito_imv_getdata.json") geo = _parse_geo_position(data_b, lat=56.840872, lon=60.654077) recommended, lower, higher, count = _parse_price(data_c) history = _parse_placement_history(data_c) suggestions, search_url = _parse_suggestions(data_c) e = IMVEvaluation( cache_key="c" * 64, address="Екатеринбург, улица Малышева, 125", rooms=2, area_m2=54.0, floor=4, floor_at_home=9, house_type="panel", renovation_type="cosmetic", has_balcony=True, has_loggia=False, geo=geo, recommended_price=recommended, lower_price=lower, higher_price=higher, market_count=count, placement_history=history, suggestions=suggestions, search_similar_url=search_url, raw_response=data_c, ) assert e.geo.geo_hash # non-empty assert e.recommended_price == 7370000 assert e.lower_price == 7148900 assert e.higher_price == 7738500 assert e.market_count == 800 assert len(e.suggestions) >= 1