gendesign/tradein-mvp/backend/tests/test_asking_sold_ratio_cache_poison.py
bot-backend 9a9f13d513
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m36s
fix(tradein/estimator): savepoint+retry вокруг ratio-lookup — transient не гасит expected_sold (#2265)
2026-07-03 10:19:57 +03:00

106 lines
4.2 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.

"""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)