"""Unit tests for ppm2-tier asking->sold ratio lookup (#928). Tests for the _get_asking_sold_ratio helper with tier_aware_ratio_enabled flag. Five scenarios: 1. flag OFF -> uses old asking_to_sold_ratios table (byte-identical to pre-#928). 2. flag ON, anchor in low band -> returns shrunk tier ratio, basis startswith 'per_rooms_tier'. 3. flag ON, no tier row for that cell -> fallback to 'all' (basis 'per_rooms_all'). 4. flag ON, no 'all' row either -> global (-1) fallback (basis 'global_all'). 5. flag ON, no tables / empty -> (None, None), no raise. Static callers audit: _get_asking_sold_ratio gained anchor_ppm2 param (default None), so all existing call sites using positional (db, rooms) continue to work unchanged. """ from __future__ import annotations import os from typing import Any from unittest.mock import MagicMock, patch # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") class _FakeRow: """Stand-in for a SQLAlchemy Row.""" def __init__(self, **kw: Any) -> None: for k, v in kw.items(): setattr(self, k, v) def _clear_ratio_cache() -> None: from app.services import estimator estimator._asking_sold_ratio_cache.clear() # ── helper: build a MagicMock db returning consecutive fetchone() results ───── def _db_sequence(rows: list[Any]) -> MagicMock: """MagicMock db whose execute().fetchone() calls return `rows` in order.""" db = MagicMock() db.execute.return_value.fetchone.side_effect = rows return db # ── 1. Flag OFF -> old table, ignores anchor_ppm2 ──────────────────────────── def test_flag_off_uses_old_table_ignores_anchor_ppm2() -> None: """Flag OFF: _get_asking_sold_ratio(db, 2, anchor_ppm2=90000) hits asking_to_sold_ratios.""" from app.core.config import settings from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() db = _db_sequence([_FakeRow(ratio=0.80, basis="per_rooms")]) with patch.object(settings, "tier_aware_ratio_enabled", False): ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0) assert ratio == 0.80 assert basis == "per_rooms" # Only one DB query (old path, no tier tables touched) assert db.execute.call_count == 1 # The query must target asking_to_sold_ratios (old table) sql_str = str(db.execute.call_args_list[0].args[0]) assert "asking_to_sold_ratios" in sql_str assert "tiered" not in sql_str def test_flag_off_no_anchor_ppm2_still_works() -> None: """Flag OFF with no anchor_ppm2 (positional call) still works as before.""" from app.core.config import settings from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() db = _db_sequence([_FakeRow(ratio=0.82, basis="per_rooms")]) with patch.object(settings, "tier_aware_ratio_enabled", False): ratio, basis = _get_asking_sold_ratio(db, 1) assert ratio == 0.82 assert basis == "per_rooms" def test_flag_off_cache_key_is_legacy_not_none() -> None: """Flag OFF must cache under (bucket, '_legacy'), NOT (bucket, None). This prevents key collision with the flag-ON Step-3 'all' fallback which also caches under (bucket, None). If the process flips tier_aware_ratio_enabled ON without restart, the '_legacy' entry is not accidentally served as the 'all' row. """ from app.core.config import settings from app.services import estimator from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() bucket = min(max(2, 0), 4) db = _db_sequence([_FakeRow(ratio=0.77, basis="per_rooms")]) with patch.object(settings, "tier_aware_ratio_enabled", False): _get_asking_sold_ratio(db, 2) # Flag-OFF entry must be under "_legacy", not None. assert (bucket, "_legacy") in estimator._asking_sold_ratio_cache assert (bucket, None) not in estimator._asking_sold_ratio_cache # ── 2. Flag ON, anchor in low band -> shrunk tier ratio ────────────────────── def test_flag_on_low_band_returns_shrunk_tier_ratio() -> None: """Flag ON, anchor < t33 -> tier='low', shrunk = tier*w + all*(1-w).""" from app.core.config import settings from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() # DB calls sequence: # 1. bounds (t33=130000, t66=165000) # 2. tier row (ratio=0.91, n_deals=200) # 3. 'all' row (ratio=0.80) db = _db_sequence( [ _FakeRow(t33=130_000, t66=165_000), # bounds _FakeRow(ratio=0.91, n_deals=200), # tier row _FakeRow(ratio=0.80), # 'all' row ] ) k = 150 w = 200 / (200 + k) expected_ratio = 0.91 * w + 0.80 * (1.0 - w) with ( patch.object(settings, "tier_aware_ratio_enabled", True), patch.object(settings, "tier_ratio_shrink_k", k), ): ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0) assert ratio is not None assert abs(ratio - expected_ratio) < 1e-9 assert basis is not None assert basis.startswith("per_rooms_tier") assert "low" in basis # ── 3. Flag ON, no tier row -> fallback to 'all' ───────────────────────────── def test_flag_on_no_tier_row_falls_back_to_all() -> None: """Flag ON, anchor in mid band, but no tier row for (bucket,mid) -> 'all' fallback.""" from app.core.config import settings from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() # DB calls: # 1. bounds (t33=130000, t66=165000) # 2. tier row -> None (cell not populated) # 3. 'all' row (ratio=0.80) -- used as both shrink-target AND fallback # Since tier_row is None, we skip shrink and fall through to step 3. db = _db_sequence( [ _FakeRow(t33=130_000, t66=165_000), # bounds None, # tier row missing _FakeRow(ratio=0.80), # 'all' row (shrink target query) _FakeRow(ratio=0.80), # 'all' row (fallback step 3) ] ) with ( patch.object(settings, "tier_aware_ratio_enabled", True), patch.object(settings, "tier_ratio_shrink_k", 150), ): ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=145_000.0) assert ratio == 0.80 assert basis == "per_rooms_all" # ── 4. Flag ON, no 'all' row -> global fallback ─────────────────────────────── def test_flag_on_no_all_row_falls_back_to_global() -> None: """Flag ON, bounds present, tier+all rows missing -> global -1 fallback.""" from app.core.config import settings from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() db = _db_sequence( [ _FakeRow(t33=130_000, t66=165_000), # bounds None, # tier row -> None None, # 'all' shrink-target -> None None, # step-3 'all' fallback -> None _FakeRow(ratio=0.79), # step-4 global (-1, all) ] ) with patch.object(settings, "tier_aware_ratio_enabled", True): ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=145_000.0) assert ratio == 0.79 assert basis == "global_all" # ── 5. Flag ON, no tables / empty -> (None, None), no raise ────────────────── def test_flag_on_no_tables_returns_none_none() -> None: """Flag ON, db.execute raises (tables not migrated) -> (None, None), no raise.""" from app.core.config import settings from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() db = MagicMock() db.execute.side_effect = RuntimeError("relation asking_to_sold_tier_bounds does not exist") with patch.object(settings, "tier_aware_ratio_enabled", True): result = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0) assert result == (None, None) # rollback must be called on error db.rollback.assert_called() # ── 6. Flag ON, no bounds row -> step-3 'all' fallback (skip tier entirely) ── def test_flag_on_no_bounds_row_falls_back_to_all() -> None: """Flag ON, bounds row None -> skip tier, go straight to 'all' step 3.""" from app.core.config import settings from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() db = _db_sequence( [ None, # bounds -> None (bucket has no bound data) _FakeRow(ratio=0.80), # step-3 'all' row ] ) with patch.object(settings, "tier_aware_ratio_enabled", True): ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0) assert ratio == 0.80 assert basis == "per_rooms_all" # ── 7. Cache: (bucket, tier) key memoises per tier ─────────────────────────── def test_tier_cache_key_is_bucket_tier_tuple() -> None: """Second call with same (bucket, anchor in same tier) -> served from cache after bounds. Flow for second call: - cache_key starts as (bucket, None) -- miss - fetches bounds (1 DB query) -> determines tier_key="low" - cache_key = (bucket, "low") -- HIT (populated by first call) - returns cached ratio without further DB queries Total DB queries: first call (3) + second call (1 bounds) = 4. """ from app.core.config import settings from app.services.estimator import _get_asking_sold_ratio _clear_ratio_cache() db = _db_sequence( [ # First call: bounds + tier_row + all_row _FakeRow(t33=130_000, t66=165_000), _FakeRow(ratio=0.91, n_deals=200), _FakeRow(ratio=0.80), # Second call: bounds only (tier cache hit after that) _FakeRow(t33=130_000, t66=165_000), ] ) with ( patch.object(settings, "tier_aware_ratio_enabled", True), patch.object(settings, "tier_ratio_shrink_k", 150), ): first = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0) second = _get_asking_sold_ratio(db, 2, anchor_ppm2=95_000.0) # same 'low' tier # Both calls return the same result (same tier). assert first == second # First call: 3 queries (bounds + tier_row + all_row). # Second call: 1 query (bounds) + cache hit on (bucket, "low"). assert db.execute.call_count == 4