"""Regression: транзиентная ошибка НЕ должна отравлять asking→sold ratio-кэш. Баг: `_get_asking_sold_ratio` gracefully ловит любую ошибку БД → (None, None), а кэш писался БЕЗУСЛОВНО → один poisoned-tx / коннект-хиккап отравлял `_asking_sold_ratio_cache[bucket] = (None, None)` на TTL (300с) и молча гасил expected_sold («ожидаемая цена сделки» → «—») для ВСЕХ оценок этого rooms-бакета на воркере до истечения TTL. Фикс: ранний return из except БЕЗ записи в кэш; кэшируем только успешный lookup. No DB: fake-Session (monkeypatched .execute/.rollback). DATABASE_URL нужен только для импорта app.core.config.Settings (тот же паттерн, что в test_estimator_pure_units). """ import os from collections.abc import Iterator from contextlib import contextmanager from types import SimpleNamespace from typing import Any os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest from app.services import estimator class _Result: def __init__(self, row: Any) -> None: self._row = row def fetchone(self) -> Any: return self._row class _OkDB: """Fake Session: первый SELECT (per-rooms bucket) возвращает строку с ratio.""" def __init__(self, ratio: float, basis: str = "per_rooms") -> None: self._row = SimpleNamespace(ratio=ratio, basis=basis) self.rolled_back = False @contextmanager def begin_nested(self) -> Iterator[Any]: # #2265 D2: ratio-lookup обёрнут в SAVEPOINT — моделируем CM. yield SimpleNamespace() def execute(self, *_a: Any, **_k: Any) -> _Result: return _Result(self._row) def rollback(self) -> None: self.rolled_back = True class _BoomDB: """Fake Session: execute всегда падает (poisoned tx / хиккап).""" def __init__(self) -> None: self.rolled_back = False @contextmanager def begin_nested(self) -> Iterator[Any]: yield SimpleNamespace() def execute(self, *_a: Any, **_k: Any) -> _Result: raise RuntimeError("InFailedSqlTransaction") def rollback(self) -> None: self.rolled_back = True @pytest.fixture(autouse=True) def _clear_ratio_cache() -> Any: """Чистим модульный кэш до/после каждого теста (глобальное состояние).""" estimator._asking_sold_ratio_cache.clear() yield estimator._asking_sold_ratio_cache.clear() def test_transient_error_returns_none_and_does_not_cache() -> None: db = _BoomDB() ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type] assert (ratio, basis) == (None, None) assert db.rolled_back is True # bucket=2 НЕ должен попасть в кэш — иначе следующая оценка застрянет на None. assert 2 not in estimator._asking_sold_ratio_cache def test_transient_error_then_success_retries_not_poisoned() -> None: # 1) транзиентный сбой — None, кэш не отравлен r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type] assert r1 is None assert 2 not in estimator._asking_sold_ratio_cache # 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type] assert r2 == pytest.approx(0.84) assert basis2 == "per_rooms" # теперь успешный результат закэширован assert estimator._asking_sold_ratio_cache[2][0] == pytest.approx(0.84) def test_successful_lookup_is_cached() -> None: r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type] assert r == pytest.approx(0.77) assert basis == "per_rooms" cached = estimator._asking_sold_ratio_cache.get(1) assert cached is not None and cached[0] == pytest.approx(0.77)