All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m21s
- Создан tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql:
set_config GUC + format('%L') + \o /dev/null — зеркальный паттерн
ops/db-bootstrap/set_tradein_fdw_password.sql. Пароль не попадает
в stdout/logs (leak-protected).
- deploy-tradein.yml: шаг bootstrap переписан на stdin-redirect
(< ops/db-bootstrap/set_gendesign_reader_password.sql) вместо сломанного
-c "ALTER ROLE ... PASSWORD :'TRADEIN_READER_PASSWORD'", которое давало
syntax error at ':' (psql не интерполирует :'var' внутри -c строки).
- newbuilding_crossload.py ON CONFLICT DO UPDATE: yandex_jk_id и
cian_internal_house_id → COALESCE(EXCLUDED.col, table.col), чтобы
cian-запись не затирала yandex_jk_id NULL'ом и наоборот.
- Тест test_upsert_sql_coalesce_external_ids проверяет наличие COALESCE
в DO UPDATE секции для обоих id.
171 lines
6.7 KiB
Python
171 lines
6.7 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()
|
||
|
||
|
||
def test_upsert_sql_coalesce_external_ids():
|
||
"""yandex_jk_id и cian_internal_house_id используют COALESCE в DO UPDATE.
|
||
|
||
Источники cian/yandex пишут попеременно — важно не затирать чужой id NULL'ом.
|
||
Проверяем: в секции DO UPDATE каждая из двух колонок встречается после COALESCE,
|
||
а не как голое EXCLUDED.<col> (что затёрло бы чужой id NULL'ом).
|
||
"""
|
||
sql_text = str(_UPSERT_SQL)
|
||
sql_upper = sql_text.upper()
|
||
|
||
do_update_idx = sql_upper.index("DO UPDATE SET")
|
||
do_update_section = sql_upper[do_update_idx:]
|
||
|
||
# COALESCE должен присутствовать в секции DO UPDATE
|
||
assert "COALESCE" in do_update_section, "DO UPDATE должен содержать COALESCE"
|
||
|
||
# yandex_jk_id — строка вида "YANDEX_JK_ID = COALESCE("
|
||
assert (
|
||
"YANDEX_JK_ID = COALESCE(" in do_update_section
|
||
), "yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
|
||
# cian_internal_house_id — строка вида "CIAN_INTERNAL_HOUSE_ID = COALESCE("
|
||
assert (
|
||
"CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section
|
||
), "cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
|