Spaces:
Sleeping
Sleeping
| """Endpoint tests for the FastAPI app. | |
| Network functions (Numbeo scrape, Yahoo FX) are monkeypatched at their | |
| import site in app.main, so these tests never touch the network. | |
| """ | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from app.fx import FxUnavailable | |
| from app.main import app, _fx_fmt | |
| client = TestClient(app) | |
| def test_fx_fmt_adaptive_precision(): | |
| assert _fx_fmt(0.0000730) == "0.00007300" # tiny rate keeps sig figs | |
| assert _fx_fmt(0.3174) == "0.3174" | |
| assert _fx_fmt(13698.6301) == "13,698.63" # big rate: separators, 2dp | |
| assert _fx_fmt(None) == "—" | |
| async def fake_diffs(country1, city1, country2, city2): | |
| return { | |
| "city_from": city1, | |
| "city_to": city2, | |
| "col_excl_rent": {"valuePct": 134.5, "direction": "higher"}, | |
| "rent": {"valuePct": 409.4, "direction": "higher"}, | |
| } | |
| async def fake_fx(from_currency, to_currency): | |
| return { | |
| "pair": f"{from_currency}/{to_currency}", | |
| "source": "Yahoo Finance", | |
| "latest": 0.30, | |
| "ema_30": 0.30, | |
| "ema_90": 0.31, | |
| "ema_180": 0.33, | |
| "forecast": 0.315, | |
| "trend": "down", | |
| "spot_only": False, | |
| "scale": 1, | |
| "band_low": 0.30, | |
| "band_high": 0.33, | |
| "days": 200, | |
| } | |
| def patched(monkeypatch): | |
| monkeypatch.setattr("app.main.get_percentage_diff", fake_diffs) | |
| monkeypatch.setattr("app.main.fetch_fx_ema", fake_fx) | |
| # ── GET / ───────────────────────────────────────────────────────── | |
| def test_landing_page_lists_both_tools(): | |
| r = client.get("/") | |
| assert r.status_code == 200 | |
| assert "Relocation Finance Tools" in r.text | |
| assert 'href="/relo"' in r.text | |
| assert 'href="/tax"' in r.text | |
| def test_relo_page_renders(): | |
| r = client.get("/relo") | |
| assert r.status_code == 200 | |
| assert "inferred from countries" in r.text | |
| assert "increase % of savings" in r.text | |
| # From / To each render on their own row | |
| assert r.text.count('class="loc-row"') == 2 | |
| # Countries are dropdowns populated from the currency map | |
| assert '<select id="country1"' in r.text | |
| assert '<option value="Malaysia"' in r.text | |
| # No country pre-selected, and no pre-filled city/salary defaults | |
| assert "selected" not in r.text | |
| assert 'name="city1" form="calc" value=""' in r.text | |
| assert 'name="net_home" form="calc" value=""' in r.text | |
| # ── POST /compare ───────────────────────────────────────────────── | |
| def test_same_country_no_fx(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Malaysia", "city2": "Penang", | |
| "net_home": "10000", "net_new": "11000", | |
| }) | |
| assert r.status_code == 200 | |
| assert "Monthly savings estimate" in r.text | |
| # No FX card for same-country (card title only renders cross-country) | |
| assert "FX prediction · next month" not in r.text | |
| def test_cross_country_with_new_salary(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Singapore", "city2": "Singapore", | |
| "net_home": "16000", "net_new": "24000", | |
| }) | |
| assert r.status_code == 200 | |
| assert "FX forecast · next month" in r.text | |
| assert "1 MYR =" in r.text | |
| # trend pill rendered (fake_fx → "down") | |
| assert 'class="fx-trend down"' in r.text | |
| # cost breakdown rendered as a comparison table | |
| assert "breakdown-table" in r.text | |
| assert "Other living" in r.text | |
| def test_head_root_ok_for_health_check(): | |
| r = client.head("/") | |
| assert r.status_code == 200 | |
| def test_get_compare_redirects_to_form(): | |
| r = client.get("/compare", follow_redirects=False) | |
| assert r.status_code == 303 | |
| assert r.headers["location"] == "/relo" | |
| def test_amounts_use_thousand_separators(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Singapore", "city2": "Singapore", | |
| "net_home": "160000", "net_new": "240000", | |
| }) | |
| assert r.status_code == 200 | |
| assert "160,000" in r.text | |
| assert "240,000" in r.text | |
| def test_cross_country_with_savings_target(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Singapore", "city2": "Singapore", | |
| "net_home": "16000", "increment_pct": "20", | |
| }) | |
| assert r.status_code == 200 | |
| # The derived-salary note should appear. | |
| assert "To save" in r.text | |
| assert "destination salary" in r.text | |
| def test_missing_salary_and_increment_shows_error(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Singapore", "city2": "Singapore", | |
| "net_home": "16000", | |
| }) | |
| assert r.status_code == 200 | |
| assert "Provide either a new net salary" in r.text | |
| def test_invalid_city_characters_shows_error(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "KL; DROP TABLE", | |
| "country2": "Singapore", "city2": "Singapore", | |
| "net_home": "16000", "net_new": "24000", | |
| }) | |
| assert r.status_code == 200 | |
| assert "invalid characters" in r.text | |
| def test_unknown_country_currency_warning(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Narnia", "city2": "Cair Paravel", | |
| "net_home": "16000", "net_new": "24000", | |
| }) | |
| assert r.status_code == 200 | |
| # Jinja escapes the apostrophe in "Couldn't"; match the unambiguous part. | |
| assert "determine the currency for Narnia" in r.text | |
| # No FX prediction card when currency can't be inferred. | |
| assert "FX prediction · next month" not in r.text | |
| def test_budget_sliders_reflected_in_result(patched): | |
| # Savings 50%, rent share 50% → of income: rent 25%, living 25%, savings 50%. | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Malaysia", "city2": "Penang", | |
| "net_home": "10000", "net_new": "11000", | |
| "savings_ratio": "50", "rent_share": "50", | |
| }) | |
| assert r.status_code == 200 | |
| assert "25.0% rent" in r.text | |
| assert "25.0% living costs" in r.text | |
| assert "50% savings" in r.text | |
| def test_invalid_slider_value_rejected(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Malaysia", "city2": "Penang", | |
| "net_home": "10000", "net_new": "11000", | |
| "savings_ratio": "150", | |
| }) | |
| assert r.status_code == 200 | |
| assert "between 0 and 100" in r.text | |
| def test_fx_failure_degrades_gracefully(monkeypatch): | |
| # Numbeo succeeds, but FX is rate-limited: show indices + warning, skip savings. | |
| async def boom(*a, **k): | |
| raise FxUnavailable("rate limited") | |
| monkeypatch.setattr("app.main.get_percentage_diff", fake_diffs) | |
| monkeypatch.setattr("app.main.fetch_fx_ema", boom) | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Singapore", "city2": "Singapore", | |
| "net_home": "16000", "net_new": "24000", | |
| }) | |
| assert r.status_code == 200 | |
| # Cost indices still shown | |
| assert "Cost of living indices" in r.text | |
| # Degradation warning shown, savings + FX cards skipped | |
| assert "could not be retrieved" in r.text | |
| assert "Monthly savings estimate" not in r.text | |
| assert "FX prediction · next month" not in r.text | |
| def test_negative_salary_rejected(patched): | |
| r = client.post("/compare", data={ | |
| "country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Malaysia", "city2": "Penang", | |
| "net_home": "-5000", "net_new": "6000", | |
| }) | |
| assert r.status_code == 200 | |
| assert "positive number" in r.text | |
| # ── coverage: helper branches ───────────────────────────────────── | |
| import httpx # noqa: E402 | |
| from app.main import _validate_place, _num_str, _to_amount # noqa: E402 | |
| def test_fx_fmt_zero(): | |
| assert _fx_fmt(0) == "0.00" | |
| def test_validate_place_empty_raises(): | |
| with pytest.raises(ValueError, match="must not be empty"): | |
| _validate_place("City", " ") | |
| def test_num_str_non_integer_keeps_decimal(): | |
| assert _num_str(3.5) == "3.5" | |
| assert _num_str(4.0) == "4" | |
| def test_to_amount_required_but_empty_raises(): | |
| with pytest.raises(ValueError, match="is required"): | |
| _to_amount("Salary", "", required=True) | |
| # ── coverage: /compare error handling & branches ────────────────── | |
| async def fake_fx_ok(from_currency, to_currency): | |
| r = 0.30 | |
| return {"pair": f"{from_currency}/{to_currency}", "source": "x", "latest": r, | |
| "ema_30": r, "ema_90": r, "ema_180": r, "forecast": r, "trend": "flat", | |
| "spot_only": False, "scale": 1, "band_low": r, "band_high": r, "days": 200} | |
| def _raise(exc): | |
| async def _f(*a, **k): | |
| raise exc | |
| return _f | |
| def _post(**overrides): | |
| data = {"country1": "Malaysia", "city1": "Kuala Lumpur", | |
| "country2": "Singapore", "city2": "Singapore", | |
| "net_home": "8000", "net_new": "6000", | |
| "savings_ratio": "20", "rent_share": "25"} | |
| data.update(overrides) | |
| return client.post("/compare", data=data) | |
| def test_compare_http_status_error(monkeypatch): | |
| monkeypatch.setattr("app.main.fetch_fx_ema", fake_fx_ok) | |
| req = httpx.Request("GET", "https://www.numbeo.com") | |
| resp = httpx.Response(503, request=req) | |
| monkeypatch.setattr("app.main.get_percentage_diff", | |
| _raise(httpx.HTTPStatusError("boom", request=req, response=resp))) | |
| assert "503" in _post().text | |
| def test_compare_request_error(monkeypatch): | |
| monkeypatch.setattr("app.main.fetch_fx_ema", fake_fx_ok) | |
| monkeypatch.setattr("app.main.get_percentage_diff", _raise(httpx.RequestError("down"))) | |
| assert "Network error" in _post().text | |
| def test_compare_runtime_error(monkeypatch): | |
| monkeypatch.setattr("app.main.fetch_fx_ema", fake_fx_ok) | |
| monkeypatch.setattr("app.main.get_percentage_diff", _raise(RuntimeError("layout changed"))) | |
| assert "Could not parse" in _post().text | |
| def test_compare_unexpected_error(monkeypatch): | |
| monkeypatch.setattr("app.main.fetch_fx_ema", fake_fx_ok) | |
| monkeypatch.setattr("app.main.get_percentage_diff", _raise(Exception("weird"))) | |
| assert "unexpected error" in _post().text.lower() | |
| def test_compare_negative_new_salary(monkeypatch): | |
| monkeypatch.setattr("app.main.get_percentage_diff", fake_diffs) | |
| monkeypatch.setattr("app.main.fetch_fx_ema", fake_fx_ok) | |
| assert "positive number" in _post(net_new="-100").text | |
| def test_compare_same_currency_different_country(monkeypatch): | |
| # Germany & Netherlands are both EUR → cross-country but no FX needed. | |
| monkeypatch.setattr("app.main.get_percentage_diff", fake_diffs) | |
| r = client.post("/compare", data={ | |
| "country1": "Germany", "city1": "Berlin", | |
| "country2": "Netherlands", "city2": "Amsterdam", | |
| "net_home": "3000", "net_new": "3500", | |
| "savings_ratio": "20", "rent_share": "25"}) | |
| assert r.status_code == 200 | |
| def test_unknown_url_returns_html_404_not_json(): | |
| r = client.get("/no-such-page") | |
| assert r.status_code == 404 | |
| assert "text/html" in r.headers["content-type"] | |
| assert "doesn" in r.text # "doesn't exist" | |
| assert "Back to the tools" in r.text | |
| def test_wrong_method_returns_html_405_not_json(): | |
| r = client.delete("/compare") | |
| assert r.status_code == 405 | |
| assert "text/html" in r.headers["content-type"] | |
| assert "isn" in r.text # "isn't allowed" | |
| def test_malformed_body_returns_html_not_json(): | |
| r = client.post( | |
| "/compare", content=b"garbage", | |
| headers={"content-type": "multipart/form-data; boundary=x"}, | |
| ) | |
| assert r.status_code == 400 | |
| assert "text/html" in r.headers["content-type"] | |
| assert "<" not in r.text and "{" != r.text.strip()[:1] # not a JSON blob | |
| async def test_validation_error_handler_renders_html(): | |
| """The RequestValidationError safety net renders HTML, not JSON 422.""" | |
| from starlette.requests import Request | |
| from fastapi.exceptions import RequestValidationError | |
| from app.main import _validation_exception_html | |
| scope = {"type": "http", "method": "POST", "path": "/compare", | |
| "headers": [], "query_string": b""} | |
| resp = await _validation_exception_html(Request(scope), RequestValidationError([])) | |
| assert resp.status_code == 422 | |
| body = resp.body.decode() | |
| assert "422" in body and "invalid" in body | |
| def test_compare_empty_submit_returns_html_error_not_json(): | |
| """Clicking Compare with nothing filled in must render a friendly HTML error, | |
| not FastAPI's raw JSON 422.""" | |
| r = client.post("/compare", data={}) | |
| assert r.status_code == 200 | |
| assert "text/html" in r.headers["content-type"] | |
| assert "must not be empty" in r.text | |
| def test_compare_partial_submit_returns_html_error(): | |
| r = client.post("/compare", data={"country1": "Malaysia"}) | |
| assert r.status_code == 200 | |
| assert "text/html" in r.headers["content-type"] | |
| assert "City 1 must not be empty." in r.text | |
| def test_head_access_log_filter_drops_head_keeps_others(): | |
| """The keep-alive HEAD poke is suppressed from access logs; real traffic stays.""" | |
| import logging | |
| from app.main import _SuppressHeadAccessLog | |
| f = _SuppressHeadAccessLog() | |
| def rec(method): | |
| return logging.LogRecord( | |
| "uvicorn.access", logging.INFO, "", 0, | |
| '%s - "%s %s HTTP/%s" %d', | |
| ("127.0.0.1", method, "/", "1.1", 200), None, | |
| ) | |
| assert f.filter(rec("HEAD")) is False # keep-alive poke suppressed | |
| assert f.filter(rec("GET")) is True # real request kept | |
| # Defensive: a record without the expected tuple args is kept. | |
| plain = logging.LogRecord("uvicorn.access", logging.INFO, "", 0, "msg", None, None) | |
| assert f.filter(plain) is True | |
| def test_analytics_beacon_absent_by_default(): | |
| assert "cloudflareinsights.com" not in client.get("/").text | |
| def test_analytics_beacon_rendered_when_token_set(): | |
| from app.main import templates | |
| prev = templates.env.globals.get("cf_analytics_token", "") | |
| templates.env.globals["cf_analytics_token"] = "TESTTOKEN123" | |
| try: | |
| body = client.get("/").text | |
| assert "cloudflareinsights.com/beacon.min.js" in body | |
| assert "TESTTOKEN123" in body | |
| finally: | |
| templates.env.globals["cf_analytics_token"] = prev | |