spb/countryatlas
Public
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