gendesign/backend/tests/services/test_newbuilding_crossload.py
bot-backend 04da30b57a
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
fix(sf): psql stdin-bootstrap пароля gendesign_reader + COALESCE-protect внешних id (review fixup, #976)
- Создан 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.
2026-06-12 11:26:40 +03:00

171 lines
6.7 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()
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'ом"