- 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
145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
"""Тесты 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()
|