SPB Git

spb/countryatlas

Public
11.3 KB · 200 lines python
1"""Infrastructure behaviour: run header, cache invalidation on snapshot swap, empty DB (503), rate limit, admin guard, OpenAPI, formatting."""2from __future__ import annotations34import os5import shutil6import time7from pathlib import Path89import duckdb10from fastapi.testclient import TestClient1112from countryatlas.api.cache import ResponseCache13from countryatlas.api.formatting import compact_number, format_change, format_value14from countryatlas.api.main import create_app15from countryatlas.api.provenance import source_url16from countryatlas.config import settings17from tests.api.conftest import RUN_ID181920def test_run_header_and_cache(client):21    r1 = client.get("/api/v1/countries/FRA")22    assert r1.headers["x-countryatlas-run"] == RUN_ID and r1.headers["x-cache"] == "MISS"23    r2 = client.get("/api/v1/countries/FRA")24    assert r2.headers["x-cache"] == "HIT" and r2.json() == r1.json()25    assert "x-cache" not in {k.lower() for k in client.get("/api/v1/countries/FRA/download.csv").headers}  # downloads not cached26    assert client.get("/api/v1/countries/FRA?x=1").headers["x-cache"] == "MISS"  # different query → different key272829def test_cache_key_includes_run_id():30    c = ResponseCache(maxsize=2)31    c.set(ResponseCache.key("run1", "/x", "a=1"), 1)32    assert c.get(ResponseCache.key("run1", "/x", "a=1")) == 133    assert c.get(ResponseCache.key("run2", "/x", "a=1")) is None34    assert ResponseCache.key("r", "/x", {"b": 2, "a": 1}) == ("r", "/x", "a=1&b=2")35    c.set(("k2",), 2)36    c.set(("k3",), 3)37    assert c.get(ResponseCache.key("run1", "/x", "a=1")) is None  # LRU evicted38    assert c.stats()["size"] == 2394041def test_snapshot_swap_reopens(tmp_path: Path, fixture_db: Path):42    db_path = tmp_path / "atlas.duckdb"43    shutil.copy(fixture_db, db_path)44    app = create_app(db_path, rate_limit_per_minute=0, cache=ResponseCache())45    with TestClient(app) as c:46        assert c.get("/api/v1/health").json()["run_id"] == RUN_ID47        assert c.get("/api/v1/countries/CAN").headers["x-cache"] == "MISS"48        assert c.get("/api/v1/countries/CAN").headers["x-cache"] == "HIT"49        # build a new snapshot with a different run_id and swap it atomically (new inode)50        new = tmp_path / "build.duckdb"51        shutil.copy(fixture_db, new)52        con = duckdb.connect(str(new))53        con.execute("UPDATE meta SET value = 'fixture-NEXT' WHERE key = 'build_run_id'")54        con.execute("UPDATE observations SET value = 12345 WHERE country_id = 'CAN' AND indicator_id = 'population' AND year = 2024")55        con.execute("UPDATE latest SET value = 12345 WHERE country_id = 'CAN' AND indicator_id = 'population'")56        con.close()57        os.replace(new, db_path)58        h = c.get("/api/v1/health").json()59        assert h["run_id"] == "fixture-NEXT"60        r = c.get("/api/v1/countries/CAN")61        assert r.headers["x-countryatlas-run"] == "fixture-NEXT" and r.headers["x-cache"] == "MISS"62        pop = next(m for m in r.json()["headline"] if m["indicator"] == "population")63        assert pop["value"] == 1234564        # file removed → 503 problem+json, health = empty; file back → recovers65        os.remove(db_path)66        assert c.get("/api/v1/health").json()["status"] == "empty"67        r = c.get("/api/v1/countries/CAN")68        assert r.status_code == 503 and r.json()["title"] == "Data not built yet" and r.headers["content-type"].startswith("application/problem+json")69        shutil.copy(fixture_db, db_path)70        assert c.get("/api/v1/countries/CAN").status_code == 200717273def test_empty_database(tmp_path: Path):74    app = create_app(tmp_path / "missing.duckdb", rate_limit_per_minute=0, cache=ResponseCache())75    with TestClient(app, raise_server_exceptions=False) as c:76        h = c.get("/api/v1/health")77        assert h.status_code == 200 and h.json()["status"] == "empty" and h.json()["observations"] == 078        for p in ("/api/v1/countries", "/api/v1/home", "/api/v1/search?q=x", "/api/v1/indicators/gdp", "/api/v1/rankings/gdp"):79            r = c.get(p)80            assert r.status_code == 503, p81            assert r.json()["title"] == "Data not built yet" and r.headers.get("retry-after")82        assert c.get("/api/v1/methodology").status_code == 200  # registry-only83        assert c.get("/api/v1/openapi.json").status_code == 200848586def test_rate_limit(fixture_db: Path):87    app = create_app(fixture_db, rate_limit_per_minute=3, cache=ResponseCache())88    with TestClient(app, raise_server_exceptions=False) as c:89        codes = [c.get("/api/v1/health").status_code for _ in range(5)]90        assert codes == [200] * 5  # health exempt91        codes = [c.get("/api/v1/countries/CAN", headers={"X-Forwarded-For": "203.0.113.9"}).status_code for _ in range(5)]92        assert codes[:3] == [200, 200, 200] and codes[3] == 42993        r = c.get("/api/v1/countries/CAN", headers={"X-Forwarded-For": "203.0.113.9"})94        assert r.headers["retry-after"] and r.json()["title"] == "Too many requests"95        assert c.get("/api/v1/countries/CAN", headers={"X-Forwarded-For": "203.0.113.10"}).status_code == 200  # other IP969798def test_admin_guard(client):99    assert client.get("/api/v1/admin/overview").status_code == 403100    assert client.get("/api/v1/admin/overview", headers={"X-Admin-Token": "wrong"}).status_code == 403101    ok = client.get("/api/v1/admin/overview", headers={"X-Admin-Token": "test-admin-token"})102    assert ok.status_code == 200103    body = ok.json()104    assert body["status"] == "ok" and body["counts"]["observations"] > 3000 and body["db"]["exists"]105    conns = {c["connector"]: c for c in body["connectors"]}106    assert conns["who"]["last_status"] == "failed" and conns["worldbank"]["n_ok"] == 2107    assert body["sources"] and body["cache"]["maxsize"] and body["scheduler"]["alive"] is None108    saved = settings.admin_token109    try:110        settings.admin_token = None111        r = client.get("/api/v1/admin/overview", headers={"X-Admin-Token": "test-admin-token"})112        assert r.status_code == 503 and r.json()["title"] == "Admin disabled"113    finally:114        settings.admin_token = saved115116117def test_admin_endpoints(client, tmp_path: Path):118    h = {"X-Admin-Token": "test-admin-token"}119    runs = client.get("/api/v1/admin/runs?limit=3", headers=h).json()120    assert runs["n"] == 3 and runs["items"][0]["run_id"] == RUN_ID121    assert client.get("/api/v1/admin/runs?connector=who", headers=h).json()["items"][0]["status"] == "failed"122    issues = client.get("/api/v1/admin/issues?severity=warning", headers=h).json()123    assert issues["n"] == 2 and issues["summary"][0]["code"] == "extreme_jump"124    cov = client.get("/api/v1/admin/coverage", headers=h).json()125    assert cov["n_indicators"] == 14 and cov["indicators"][0]["n_countries"] == 8 and cov["countries"][0]["coverage_pct"] == 100.0126    raw = client.get(f"/api/v1/admin/raw?run_id={RUN_ID}", headers=h).json()127    assert raw["n_runs"] == 5 and any(f.get("missing") for f in raw["files"])128    # refresh: no pid file → 409 ; pid file with own pid → SIGUSR1 delivered (handler installed)129    saved = settings.data_dir130    settings.data_dir = tmp_path131    try:132        r = client.post("/api/v1/admin/refresh", headers=h)133        assert r.status_code == 409134        import signal135136        got = []137        old = signal.signal(signal.SIGUSR1, lambda *a: got.append(1))138        (tmp_path / "scheduler.pid").write_text(str(os.getpid()))139        r = client.post("/api/v1/admin/refresh", headers=h)140        time.sleep(0.05)141        signal.signal(signal.SIGUSR1, old)142        assert r.status_code == 200 and r.json()["signal"] == "SIGUSR1" and got143        assert client.post("/api/v1/admin/cache/clear", headers=h).json()["ok"]144    finally:145        settings.data_dir = saved146147148def test_openapi_and_docs(client):149    spec = client.get("/api/v1/openapi.json").json()150    paths = set(spec["paths"])151    for p in ("/api/v1/health", "/api/v1/countries", "/api/v1/countries/{id}", "/api/v1/countries/{id}/topics/{topic}",152              "/api/v1/countries/{id}/series/{indicator}", "/api/v1/countries/{id}/changes", "/api/v1/countries/{id}/events",153              "/api/v1/countries/{id}/similar", "/api/v1/countries/{id}/insights", "/api/v1/countries/{id}/dna",154              "/api/v1/countries/{id}/download.{fmt}", "/api/v1/indicators", "/api/v1/indicators/{slug}", "/api/v1/indicators/{slug}/map",155              "/api/v1/indicators/{slug}/trend", "/api/v1/indicators/{slug}/download.{fmt}", "/api/v1/series", "/api/v1/rankings",156              "/api/v1/rankings/{indicator}", "/api/v1/rankings/{indicator}/history", "/api/v1/compare", "/api/v1/compare/snapshot",157              "/api/v1/compare/download.{fmt}", "/api/v1/regions", "/api/v1/regions/{slug}", "/api/v1/search", "/api/v1/home",158              "/api/v1/changes", "/api/v1/sources", "/api/v1/sources/{id}", "/api/v1/methodology", "/api/v1/admin/overview",159              "/api/v1/admin/runs", "/api/v1/admin/issues", "/api/v1/admin/coverage", "/api/v1/admin/raw", "/api/v1/admin/refresh"):160        assert p in paths, p161    assert "Provenance" in spec["components"]["schemas"]162    assert client.get("/api/v1/docs").status_code == 200 and client.get("/api/v1/redoc").status_code == 200163164165def test_unknown_route_and_validation(client):166    r = client.get("/api/v1/nothing-here")167    assert r.status_code == 404 and r.headers["content-type"].startswith("application/problem+json") and "/api/v1/docs" in r.json()["detail"]168    r = client.get("/api/v1/rankings/gdp?limit=0")169    assert r.status_code == 422 and r.json()["errors"][0]["loc"] == ["query", "limit"]170171172def test_formatting():173    assert format_value(53372.1, {"format": "currency"}) == "53.4k"174    assert format_value(1.23e12, {"format": "currency"}) == "1.2T"175    assert format_value(45.3e9, {"format": "currency"}) == "45.3B"176    assert format_value(3.44, {"format": "percent", "precision": 1}) == "3.4 %"177    assert format_value(82.13, {"format": "years"}) == "82.1 yrs"178    assert format_value(5.234, {"format": "tonnes", "precision": 2}) == "5.23 t"179    assert format_value(3.2, {"format": "per_1000"}) == "3.2 per 1,000"180    assert format_value(1_234_567, {"format": "number"}) == "1.2M"181    assert format_value(812.4, {"format": "number", "precision": 0}) == "812"182    assert format_value(None, {"format": "number"}) == "—"183    assert format_value(float("nan"), {"format": "number"}) == "—"184    assert compact_number(-2_500_000) == "-2.5M"185    assert format_change(1.2, 30.0, {"format": "percent"}) == "+1.2 pts"186    assert format_change(-100.0, -3.4, {"format": "currency"}) == "−3.4 %"187188189def test_source_urls():190    assert source_url("worldbank", "WDI", "NY.GDP.PCAP.CD", "CA") == "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA"191    assert source_url("owid", "grapher", "median-age") == "https://ourworldindata.org/grapher/median-age"192    assert source_url("owid", "energy", "renewables_share_elec") == "https://github.com/owid/energy-data"193    assert source_url("eurostat", "prc_hpi_a", "") == "https://ec.europa.eu/eurostat/databrowser/view/prc_hpi_a/default/table"194    assert source_url("who", "GHO", "WHOSIS_000001") == "https://www.who.int/data/gho/data/indicators/indicator-details/GHO/WHOSIS_000001"195    assert source_url("fred", "", "UNRATE") == "https://fred.stlouisfed.org/series/UNRATE"196    assert source_url("imf", "WEO", "NGDPD") == "https://data.imf.org/"197    assert source_url("bis", "", "") == "https://data.bis.org/" and source_url("ilo", "", "") == "https://ilostat.ilo.org/"198    assert source_url("oecd", "X", "Y") == "https://data-explorer.oecd.org/"199    assert source_url("unknown", "", "") is None200
Files · b828cc86d5b2223081f42e670e6d1f2379c43230
Menu

Theme