Spaces:
Sleeping
Sleeping
File size: 8,636 Bytes
28a06c2 4a82fee 28a06c2 e9ea2a3 11b094a 28a06c2 4a82fee e9ea2a3 28a06c2 4a82fee 28a06c2 4a82fee 28a06c2 4a82fee 28a06c2 4a82fee 28a06c2 4a82fee 28a06c2 4a82fee 28a06c2 e9ea2a3 4a82fee e9ea2a3 4a82fee e9ea2a3 4a82fee e9ea2a3 4a82fee 8b80811 11b094a 8b80811 11b094a 8b80811 11b094a 8b80811 11b094a 8b80811 5d65650 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | """Tests for Numbeo scraping and parsing."""
import pytest
import app.data_sources as ds
from app.data_sources import (
HOME_URL,
_index_key,
extract_city_differences,
get_percentage_diff,
)
# ββ Fake curl_cffi session ββββββββββββββββββββββββββββββββββββββββ
# curl_cffi uses libcurl, not httpx, so respx can't intercept it. Instead we
# stub _new_session() with a fake that replays canned (status, html) responses.
class _FakeResp:
def __init__(self, status_code, text=""):
self.status_code = status_code
self.text = text
class _FakeSession:
def __init__(self, compare_responses):
self._compare = list(compare_responses)
self.compare_calls = 0
self.requests = [] # (url, params) for every GET
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def get(self, url, params=None, **kwargs):
self.requests.append((url, params))
if url == HOME_URL:
return _FakeResp(200, "") # warm-up
self.compare_calls += 1
return self._compare.pop(0)
def _install(monkeypatch, *compare_responses):
"""Point _new_session at a fake replaying the given compare responses."""
session = _FakeSession(compare_responses)
monkeypatch.setattr(ds, "_new_session", lambda: session)
return session
async def _noop_sleep(_seconds):
"""Stand-in for asyncio.sleep so retry tests don't wait on real backoff."""
return None
# ββ extract_city_differences (pure) βββββββββββββββββββββββββββββββ
def test_extract_higher():
out = extract_city_differences("Cost of Living in X is 134.5% higher than in Y")
assert out == {"valuePct": 134.5, "direction": "higher"}
def test_extract_lower_is_negative():
out = extract_city_differences("Rent Prices in X are 25.5% lower than in Y")
assert out["valuePct"] == -25.5
assert out["direction"] == "lower"
def test_extract_unparseable_raises():
with pytest.raises(ValueError, match="Could not parse"):
extract_city_differences("totally unrelated text")
# ββ get_percentage_diff (stubbed session) βββββββββββββββββββββββββ
async def test_get_percentage_diff_success(monkeypatch, numbeo_html):
_install(monkeypatch, _FakeResp(200, numbeo_html))
out = await get_percentage_diff("Malaysia", "Kuala Lumpur", "Singapore", "Singapore")
assert out["col_excl_rent"] == {"valuePct": 134.5, "direction": "higher"}
assert out["rent"] == {"valuePct": 409.4, "direction": "higher"}
assert out["city_from"] == "Kuala Lumpur"
assert out["city_to"] == "Singapore"
async def test_get_percentage_diff_lower(monkeypatch, numbeo_html_lower):
_install(monkeypatch, _FakeResp(200, numbeo_html_lower))
out = await get_percentage_diff("Malaysia", "Kuala Lumpur", "Malaysia", "Penang")
assert out["col_excl_rent"]["valuePct"] == -12.0
assert out["rent"]["valuePct"] == -25.5
async def test_unknown_city_raises_valueerror(monkeypatch, numbeo_html_unknown_city):
_install(monkeypatch, _FakeResp(200, numbeo_html_unknown_city))
with pytest.raises(ValueError, match="doesn't recognise"):
await get_percentage_diff("Malaysia", "Atlantis", "Singapore", "Singapore")
async def test_missing_table_raises_runtimeerror(monkeypatch, numbeo_html_no_table):
_install(monkeypatch, _FakeResp(200, numbeo_html_no_table))
with pytest.raises(RuntimeError, match="table not found"):
await get_percentage_diff("Malaysia", "Kuala Lumpur", "Singapore", "Singapore")
async def test_non_retryable_http_error_raises(monkeypatch):
_install(monkeypatch, _FakeResp(404))
with pytest.raises(RuntimeError, match="HTTP 404"):
await get_percentage_diff("Malaysia", "Kuala Lumpur", "Singapore", "Singapore")
async def test_persistent_503_retries_then_raises(monkeypatch):
# Don't actually sleep through the backoff during tests.
monkeypatch.setattr("app.data_sources.asyncio.sleep", _noop_sleep)
session = _install(
monkeypatch, _FakeResp(503), _FakeResp(503), _FakeResp(503)
)
with pytest.raises(RuntimeError, match="temporarily unavailable"):
await get_percentage_diff("Malaysia", "Kuala Lumpur", "Singapore", "Singapore")
assert session.compare_calls == 3 # retried up to _MAX_ATTEMPTS
async def test_transient_503_then_success(monkeypatch, numbeo_html):
monkeypatch.setattr("app.data_sources.asyncio.sleep", _noop_sleep)
session = _install(
monkeypatch, _FakeResp(503), _FakeResp(200, numbeo_html)
)
out = await get_percentage_diff("Malaysia", "Kuala Lumpur", "Singapore", "Singapore")
assert out["col_excl_rent"] == {"valuePct": 134.5, "direction": "higher"}
assert session.compare_calls == 2
# ββ index layer βββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_index_key_normalises_case_and_whitespace():
assert _index_key("Malaysia", " Kuala Lumpur ") == "malaysia|kuala lumpur"
async def test_index_hit_computes_without_network(monkeypatch):
ds._INDEX[_index_key("Malaysia", "Kuala Lumpur")] = {"col": 40.0, "rent": 12.0}
ds._INDEX[_index_key("Singapore", "Singapore")] = {"col": 92.0, "rent": 36.0}
# If it hit the network the fake would raise (no responses queued).
session = _install(monkeypatch)
out = await get_percentage_diff("malaysia", "KUALA LUMPUR", "singapore", "Singapore")
# ratio idx_B/idx_A: col 92/40 β +130%, rent 36/12 β +200%
assert out["col_excl_rent"] == {"valuePct": 130.0, "direction": "higher"}
assert out["rent"] == {"valuePct": 200.0, "direction": "higher"}
assert out["city_from"] == "KUALA LUMPUR" and out["city_to"] == "Singapore"
assert session.compare_calls == 0
async def test_index_miss_falls_through_to_scrape(monkeypatch, numbeo_html):
# KL is indexed but Penang is not β the pair can't be computed β live scrape.
ds._INDEX[_index_key("Malaysia", "Kuala Lumpur")] = {"col": 40.0, "rent": 12.0}
session = _install(monkeypatch, _FakeResp(200, numbeo_html))
out = await get_percentage_diff("Malaysia", "Penang", "Singapore", "Singapore")
assert out["col_excl_rent"] == {"valuePct": 134.5, "direction": "higher"}
assert session.compare_calls == 1
# ββ coverage: fallback / error branches βββββββββββββββββββββββββββ
def test_load_index_missing_file_returns_empty(monkeypatch, tmp_path):
monkeypatch.setattr(ds, "_INDEX_PATH", tmp_path / "does_not_exist.json")
assert ds._load_index() == {}
def test_new_session_returns_impersonating_session():
from curl_cffi.requests import AsyncSession
assert isinstance(ds._new_session(), AsyncSession)
async def test_warmup_failure_is_ignored(monkeypatch, numbeo_html):
"""A failed cookie warm-up must not abort the real request."""
class _WarmupFails:
compare_calls = 0
async def __aenter__(self): return self
async def __aexit__(self, *exc): return False
async def get(self, url, params=None, **kwargs):
if url == HOME_URL:
raise RuntimeError("warm-up boom")
type(self).compare_calls += 1
return _FakeResp(200, numbeo_html)
monkeypatch.setattr(ds, "_new_session", lambda: _WarmupFails())
out = await get_percentage_diff("Malaysia", "Kuala Lumpur", "Singapore", "Singapore")
assert out["col_excl_rent"] == {"valuePct": 134.5, "direction": "higher"}
async def test_row_without_td_is_skipped(monkeypatch):
html = """<table class="table_indices_diff">
<tr><th>Index</th><th>Difference</th></tr>
<tr><td>Cost of Living in B is 10.0% higher than in A</td></tr>
<tr><td>Rent Prices in B are 20.0% higher than in A</td></tr>
</table>"""
_install(monkeypatch, _FakeResp(200, html))
out = await get_percentage_diff("A", "A", "B", "B")
assert out["col_excl_rent"]["valuePct"] == 10.0
assert out["rent"]["valuePct"] == 20.0
async def test_table_without_col_or_rent_rows_raises(monkeypatch):
html = """<table class="table_indices_diff">
<tr><td>Some unrelated row</td></tr>
</table>"""
_install(monkeypatch, _FakeResp(200, html))
with pytest.raises(RuntimeError, match="Unable to extract"):
await get_percentage_diff("A", "A", "B", "B")
|