gendesign/tradein-mvp/backend/tests/test_estimator_price_trend_dedup.py
bot-backend af1d882745 fix(tradein): Mera-audit fix-3 — cross-source dedup в _fetch_price_trend
Один объект на avito_imv + yandex_valuation с разными ext_item_id создаёт
дубликаты в house_placement_history и double-count'ится в помесячной медиане.
Решение: CTE с DISTINCT ON (round(area_m2), floor, price, price_date) с
приоритетом avito_imv через CASE sort. Легитимные разные квартиры (разные
площадь/этаж/цена) не затрагиваются.

За флагом estimate_price_trend_dedup_enabled (дефолт True в settings).
False → точно старое поведение (backward-compat). Graceful: без изменений
в логике min_points и возврата None при недостатке данных.

Тесты: 6 новых тест-кейсов в test_estimator_price_trend_dedup.py (SQL содержит
DISTINCT ON, flag OFF не содержит, Source1 preferred, данные возвращаются корректно).
2026-06-21 09:05:57 +03:00

147 lines
5.6 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.

"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation
с разными ext_item_id не double-count'ится в месячной медиане.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from app.services.estimator import _fetch_price_trend
def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMock:
"""Возвращает mock db: Source1 (houses_price_dynamics) → source1_rows,
Source2 (house_placement_history) → source2_rows."""
mock_db = MagicMock()
def side_effect(*args: Any, **kwargs: Any) -> MagicMock:
call_text = str(args[0].text) if args else ""
result = MagicMock()
if "houses_price_dynamics" in call_text:
result.mappings.return_value.all.return_value = source1_rows
else:
result.mappings.return_value.all.return_value = source2_rows
return result
mock_db.execute.side_effect = side_effect
return mock_db
# ---------------------------------------------------------------------------
# Fix-3: dedup enabled (default True)
# ---------------------------------------------------------------------------
def test_dedup_enabled_flag_is_true_by_default() -> None:
"""estimate_price_trend_dedup_enabled дефолт True."""
from app.core.config import settings
assert settings.estimate_price_trend_dedup_enabled is True
def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
"""Когда dedup включён, SQL-запрос к house_placement_history содержит DISTINCT ON."""
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
calls = mock_db.execute.call_args_list
# Второй вызов — Source 2 (house_placement_history)
assert len(calls) >= 2
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
def test_price_trend_dedup_disabled_no_distinct_on() -> None:
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path)."""
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = False
mock_settings.estimate_price_trend_max_age_months = 6
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
calls = mock_db.execute.call_args_list
assert len(calls) >= 2
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
assert "distinct on" not in second_sql.lower()
def test_price_trend_returns_result_with_dedup_enabled() -> None:
"""Функция корректно возвращает точки тренда при dedup_enabled=True."""
rows = [
{"month": "2025-11", "ppm2": 148_000},
{"month": "2025-12", "ppm2": 152_000},
{"month": "2026-01", "ppm2": 155_000},
]
mock_db = _make_mock_db([], rows)
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
assert result is not None
assert len(result) == 3
assert result[0]["month"] == "2025-11"
assert result[0]["ppm2"] == 148_000
def test_price_trend_dedup_disabled_returns_same_points() -> None:
"""Флаг OFF не ломает возврат данных (backward-compat)."""
rows = [
{"month": "2025-11", "ppm2": 148_000},
{"month": "2025-12", "ppm2": 152_000},
{"month": "2026-01", "ppm2": 155_000},
]
mock_db = _make_mock_db([], rows)
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = False
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
assert result is not None
assert len(result) == 3
def test_price_trend_source1_preferred_over_dedup_path() -> None:
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
source1_rows = [
{"month": "2025-11", "ppm2": 140_000},
{"month": "2025-12", "ppm2": 145_000},
{"month": "2026-01", "ppm2": 150_000},
]
mock_db = _make_mock_db(source1_rows, [])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
# Source1 вернул 3 точки — Source2 не должен вызываться
assert result is not None
assert len(result) == 3
calls = mock_db.execute.call_args_list
# Ровно один вызов (Source1)
assert len(calls) == 1
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))