relo-calculator / tests /test_app.py
sabilmakbar's picture
render 404, 405, and malformed-request errors as styled html pages instead of json
6d43ab4
Raw
History Blame Contribute Delete
14.9 kB
"""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,
}
@pytest.fixture
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 "&lt;" 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