gendesign/backend/tests/services/test_newbuilding_crossload.py
bot-backend 1a4778d006 feat(sf): cross-load ETL tradein→gendesign newbuilding_listings — вариант A (#976)
- docker-compose.prod.yml: backend + worker добавлены в сеть gendesign_shared
  (нужен прямой TCP к tradein-postgres для psycopg ETL)
- tradein-mvp/data/sql/101_gendesign_reader_role.sql: роль gendesign_reader
  (LOGIN, SELECT на houses/house_sources/houses_price_dynamics/
   house_reliability_checks/house_reviews; пароль через TRADEIN_READER_PASSWORD
   bootstrap в deploy-tradein.yml)
- .forgejo/workflows/deploy-tradein.yml: bootstrap ALTER ROLE gendesign_reader
  PASSWORD из env после миграций; warn (не exit 1) если переменная не задана
- backend/app/core/config.py: settings.tradein_database_url (env TRADEIN_DATABASE_URL)
- backend/app/services/etl/newbuilding_crossload.py: run_crossload() — psycopg
  server-side cursor, batch 500, UPSERT ON CONFLICT (source, ext_house_id),
  SAVEPOINT per-row; disabled-режим если url пуст
- backend/app/workers/tasks/etl_newbuilding_crossload.py: Celery task
- backend/app/workers/beat_schedule.py: nightly 03:30 МСК (00:30 UTC)
- backend/app/api/v1/admin_scrape.py: POST /admin/scrape/newbuilding-crossload
- backend/tests/services/test_newbuilding_crossload.py: 10 тестов без реальной БД

needs-human: TRADEIN_READER_PASSWORD → tradein .env.runtime;
             TRADEIN_DATABASE_URL → gendesign backend/.env.runtime
2026-06-12 11:18:47 +03:00

145 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты cross-load ETL tradein→gendesign newbuilding_listings (#976).
Тесты без реальной БД:
(a) disabled-режим: пустой tradein_database_url → {"disabled": True}, psycopg не вызывается.
(b) маппинг строки source → insert-параметры (_row_to_params).
(c) UPSERT SQL содержит ON CONFLICT (source, ext_house_id).
"""
from __future__ import annotations
import json
from unittest.mock import patch
from app.services.etl.newbuilding_crossload import (
_UPSERT_SQL,
_row_to_params,
run_crossload,
)
# ─── (a) Disabled-режим ──────────────────────────────────────────────────────
def test_run_crossload_disabled_when_url_empty():
"""Если tradein_database_url пусто — возвращает {"disabled": True}, коннекта нет."""
with patch("app.services.etl.newbuilding_crossload.settings") as mock_settings:
mock_settings.tradein_database_url = ""
with patch("psycopg.connect") as mock_connect:
result = run_crossload()
assert result == {"disabled": True}
mock_connect.assert_not_called()
# ─── (b) Маппинг _row_to_params ──────────────────────────────────────────────
_SAMPLE_ROW: dict = {
"source": "cian",
"ext_house_id": "123456",
"url": "https://cian.ru/novostrojki/123456/",
"slug": "zhk-test",
"address": "Екатеринбург, ул. Тестовая, 1",
"full_address": "Свердловская область, Екатеринбург, ул. Тестовая, 1",
"lat": 56.838_926,
"lon": 60.605_702,
"house_class": "comfort",
"developer_name": "ТестСтрой",
"developer_key": "teststr_001",
"year_built": 2025,
"total_floors": 18,
"total_units": 360,
"rating": 4.7,
"reviews_count": 12,
"houses_by_turn": [{"turn": 1, "corps": ["1А"]}],
"cian_internal_house_id": 9876543,
"raw_payload": {"foo": "bar"},
"last_scraped_at": None,
"yandex_jk_id": "yandex-42",
}
def test_row_to_params_basic_fields():
"""Прямые поля копируются без изменений."""
p = _row_to_params(_SAMPLE_ROW)
assert p["source"] == "cian"
assert p["ext_house_id"] == "123456"
assert p["url"] == "https://cian.ru/novostrojki/123456/"
assert p["house_class"] == "comfort"
assert p["developer_name"] == "ТестСтрой"
assert p["developer_key"] == "teststr_001"
assert p["year_built"] == 2025
assert p["total_floors"] == 18
assert p["total_units"] == 360
assert p["reviews_count"] == 12
assert p["yandex_jk_id"] == "yandex-42"
assert p["cian_internal_house_id"] == 9876543
def test_row_to_params_geo_duplicated():
"""lat/lon продублированы в lat2/lon2 для CASE-ветки geom."""
p = _row_to_params(_SAMPLE_ROW)
assert p["lat"] == _SAMPLE_ROW["lat"]
assert p["lon"] == _SAMPLE_ROW["lon"]
assert p["lat2"] == _SAMPLE_ROW["lat"]
assert p["lon2"] == _SAMPLE_ROW["lon"]
def test_row_to_params_jsonb_serialized():
"""houses_by_turn и raw_payload сериализованы в JSON-строки."""
p = _row_to_params(_SAMPLE_ROW)
# houses_by_turn
hbt = json.loads(p["houses_by_turn"])
assert hbt == [{"turn": 1, "corps": ["1А"]}]
# raw_payload
rp = json.loads(p["raw_payload"])
assert rp == {"foo": "bar"}
def test_row_to_params_none_jsonb():
"""Если houses_by_turn/raw_payload равны None — параметр None, не строка."""
row = dict(_SAMPLE_ROW)
row["houses_by_turn"] = None
row["raw_payload"] = None
p = _row_to_params(row)
assert p["houses_by_turn"] is None
assert p["raw_payload"] is None
def test_row_to_params_rating_as_string():
"""rating приводится к строке для CAST(:rating AS numeric)."""
p = _row_to_params(_SAMPLE_ROW)
assert p["rating"] == "4.7"
def test_row_to_params_rating_none():
"""Если rating = None — параметр тоже None."""
row = dict(_SAMPLE_ROW)
row["rating"] = None
p = _row_to_params(row)
assert p["rating"] is None
def test_row_to_params_source_last_scraped_at():
"""last_scraped_at из источника → source_last_scraped_at в параметрах."""
p = _row_to_params(_SAMPLE_ROW)
assert "source_last_scraped_at" in p
assert p["source_last_scraped_at"] is None # SAMPLE_ROW имеет None
# ─── (c) UPSERT SQL содержит ON CONFLICT ──────────────────────────────────────
def test_upsert_sql_contains_on_conflict():
"""UPSERT-запрос содержит ON CONFLICT (source, ext_house_id)."""
sql_text = str(_UPSERT_SQL)
assert "ON CONFLICT" in sql_text.upper()
assert "source" in sql_text
assert "ext_house_id" in sql_text
def test_upsert_sql_contains_do_update():
"""UPSERT-запрос обновляет строку при конфликте (DO UPDATE SET)."""
sql_text = str(_UPSERT_SQL)
assert "DO UPDATE SET" in sql_text.upper()