"""Infrastructure behaviour: run header, cache invalidation on snapshot swap, empty DB (503), rate limit, admin guard, OpenAPI, formatting.""" from __future__ import annotations import os import shutil import time from pathlib import Path import duckdb from fastapi.testclient import TestClient from countryatlas.api.cache import ResponseCache from countryatlas.api.db import invalidate_registry_caches from countryatlas.api.formatting import compact_number, format_change, format_short, format_value from countryatlas.api.main import create_app from countryatlas.api.provenance import source_url from countryatlas.config import settings from tests.api.conftest import RUN_ID def test_run_header_and_cache(client): r1 = client.get("/api/v1/countries/FRA") assert r1.headers["x-countryatlas-run"] == RUN_ID and r1.headers["x-cache"] == "MISS" r2 = client.get("/api/v1/countries/FRA") assert r2.headers["x-cache"] == "HIT" and r2.json() == r1.json() assert "x-cache" not in {k.lower() for k in client.get("/api/v1/countries/FRA/download.csv").headers} # downloads not cached assert client.get("/api/v1/countries/FRA?x=1").headers["x-cache"] == "MISS" # different query → different key def test_cache_key_includes_run_id(): c = ResponseCache(maxsize=2) c.set(ResponseCache.key("run1", "/x", "a=1"), 1) assert c.get(ResponseCache.key("run1", "/x", "a=1")) == 1 assert c.get(ResponseCache.key("run2", "/x", "a=1")) is None assert ResponseCache.key("r", "/x", {"b": 2, "a": 1}) == ("r", "/x", "a=1&b=2") c.set(("k2",), 2) c.set(("k3",), 3) assert c.get(ResponseCache.key("run1", "/x", "a=1")) is None # LRU evicted assert c.stats()["size"] == 2 def test_snapshot_swap_reopens(tmp_path: Path, fixture_db: Path): db_path = tmp_path / "atlas.duckdb" shutil.copy(fixture_db, db_path) app = create_app(db_path, rate_limit_per_minute=0, cache=ResponseCache()) with TestClient(app) as c: assert c.get("/api/v1/health").json()["run_id"] == RUN_ID assert c.get("/api/v1/countries/CAN").headers["x-cache"] == "MISS" assert c.get("/api/v1/countries/CAN").headers["x-cache"] == "HIT" # build a new snapshot with a different run_id and swap it atomically (new inode) new = tmp_path / "build.duckdb" shutil.copy(fixture_db, new) con = duckdb.connect(str(new)) con.execute("UPDATE meta SET value = 'fixture-NEXT' WHERE key = 'build_run_id'") con.execute("UPDATE observations SET value = 12345 WHERE country_id = 'CAN' AND indicator_id = 'population' AND year = 2024") con.execute("UPDATE latest SET value = 12345 WHERE country_id = 'CAN' AND indicator_id = 'population'") con.close() os.replace(new, db_path) h = c.get("/api/v1/health").json() assert h["run_id"] == "fixture-NEXT" r = c.get("/api/v1/countries/CAN") assert r.headers["x-countryatlas-run"] == "fixture-NEXT" and r.headers["x-cache"] == "MISS" pop = next(m for m in r.json()["headline"] if m["indicator"] == "population") assert pop["value"] == 12345 # file removed → 503 problem+json, health = empty; file back → recovers os.remove(db_path) assert c.get("/api/v1/health").json()["status"] == "empty" r = c.get("/api/v1/countries/CAN") assert r.status_code == 503 and r.json()["title"] == "Data not built yet" and r.headers["content-type"].startswith("application/problem+json") shutil.copy(fixture_db, db_path) assert c.get("/api/v1/countries/CAN").status_code == 200 def test_empty_database(tmp_path: Path): app = create_app(tmp_path / "missing.duckdb", rate_limit_per_minute=0, cache=ResponseCache()) with TestClient(app, raise_server_exceptions=False) as c: h = c.get("/api/v1/health") assert h.status_code == 200 and h.json()["status"] == "empty" and h.json()["observations"] == 0 for p in ("/api/v1/countries", "/api/v1/home", "/api/v1/search?q=x", "/api/v1/indicators/gdp", "/api/v1/rankings/gdp"): r = c.get(p) assert r.status_code == 503, p assert r.json()["title"] == "Data not built yet" and r.headers.get("retry-after") assert c.get("/api/v1/methodology").status_code == 200 # registry-only assert c.get("/api/v1/openapi.json").status_code == 200 def test_rate_limit(fixture_db: Path): app = create_app(fixture_db, rate_limit_per_minute=3, cache=ResponseCache()) with TestClient(app, raise_server_exceptions=False) as c: codes = [c.get("/api/v1/health").status_code for _ in range(5)] assert codes == [200] * 5 # health exempt codes = [c.get("/api/v1/countries/CAN", headers={"X-Forwarded-For": "203.0.113.9"}).status_code for _ in range(5)] assert codes[:3] == [200, 200, 200] and codes[3] == 429 r = c.get("/api/v1/countries/CAN", headers={"X-Forwarded-For": "203.0.113.9"}) assert r.headers["retry-after"] and r.json()["title"] == "Too many requests" assert c.get("/api/v1/countries/CAN", headers={"X-Forwarded-For": "203.0.113.10"}).status_code == 200 # other IP def test_admin_guard(client): assert client.get("/api/v1/admin/overview").status_code == 403 assert client.get("/api/v1/admin/overview", headers={"X-Admin-Token": "wrong"}).status_code == 403 ok = client.get("/api/v1/admin/overview", headers={"X-Admin-Token": "test-admin-token"}) assert ok.status_code == 200 body = ok.json() assert body["status"] == "ok" and body["counts"]["observations"] > 3000 and body["db"]["exists"] conns = {c["connector"]: c for c in body["connectors"]} assert conns["who"]["last_status"] == "failed" and conns["worldbank"]["n_ok"] == 2 assert body["sources"] and body["cache"]["maxsize"] and body["scheduler"]["alive"] is None saved = settings.admin_token try: settings.admin_token = None r = client.get("/api/v1/admin/overview", headers={"X-Admin-Token": "test-admin-token"}) assert r.status_code == 503 and r.json()["title"] == "Admin disabled" finally: settings.admin_token = saved def test_admin_endpoints(client, tmp_path: Path): h = {"X-Admin-Token": "test-admin-token"} runs = client.get("/api/v1/admin/runs?limit=3", headers=h).json() assert runs["n"] == 3 and runs["items"][0]["run_id"] == RUN_ID assert client.get("/api/v1/admin/runs?connector=who", headers=h).json()["items"][0]["status"] == "failed" issues = client.get("/api/v1/admin/issues?severity=warning", headers=h).json() assert issues["n"] == 2 and issues["summary"][0]["code"] == "extreme_jump" cov = client.get("/api/v1/admin/coverage", headers=h).json() assert cov["n_indicators"] == 14 and cov["indicators"][0]["n_countries"] == 8 and cov["countries"][0]["coverage_pct"] == 100.0 raw = client.get(f"/api/v1/admin/raw?run_id={RUN_ID}", headers=h).json() assert raw["n_runs"] == 5 and any(f.get("missing") for f in raw["files"]) # refresh: no pid file → 409 ; pid file with own pid → SIGUSR1 delivered (handler installed) saved = settings.data_dir settings.data_dir = tmp_path try: r = client.post("/api/v1/admin/refresh", headers=h) assert r.status_code == 409 import signal got = [] old = signal.signal(signal.SIGUSR1, lambda *a: got.append(1)) (tmp_path / "scheduler.pid").write_text(str(os.getpid())) r = client.post("/api/v1/admin/refresh", headers=h) time.sleep(0.05) signal.signal(signal.SIGUSR1, old) assert r.status_code == 200 and r.json()["signal"] == "SIGUSR1" and got assert client.post("/api/v1/admin/cache/clear", headers=h).json()["ok"] finally: settings.data_dir = saved def test_openapi_and_docs(client): spec = client.get("/api/v1/openapi.json").json() paths = set(spec["paths"]) for p in ("/api/v1/health", "/api/v1/countries", "/api/v1/countries/{id}", "/api/v1/countries/{id}/topics/{topic}", "/api/v1/countries/{id}/series/{indicator}", "/api/v1/countries/{id}/changes", "/api/v1/countries/{id}/events", "/api/v1/countries/{id}/similar", "/api/v1/countries/{id}/insights", "/api/v1/countries/{id}/dna", "/api/v1/countries/{id}/download.{fmt}", "/api/v1/indicators", "/api/v1/indicators/{slug}", "/api/v1/indicators/{slug}/map", "/api/v1/indicators/{slug}/trend", "/api/v1/indicators/{slug}/download.{fmt}", "/api/v1/series", "/api/v1/rankings", "/api/v1/rankings/{indicator}", "/api/v1/rankings/{indicator}/history", "/api/v1/compare", "/api/v1/compare/snapshot", "/api/v1/compare/download.{fmt}", "/api/v1/regions", "/api/v1/regions/{slug}", "/api/v1/search", "/api/v1/home", "/api/v1/changes", "/api/v1/sources", "/api/v1/sources/{id}", "/api/v1/methodology", "/api/v1/admin/overview", "/api/v1/admin/runs", "/api/v1/admin/issues", "/api/v1/admin/coverage", "/api/v1/admin/raw", "/api/v1/admin/refresh"): assert p in paths, p assert "Provenance" in spec["components"]["schemas"] assert client.get("/api/v1/docs").status_code == 200 and client.get("/api/v1/redoc").status_code == 200 def test_unknown_route_and_validation(client): r = client.get("/api/v1/nothing-here") assert r.status_code == 404 and r.headers["content-type"].startswith("application/problem+json") and "/api/v1/docs" in r.json()["detail"] r = client.get("/api/v1/rankings/gdp?limit=0") assert r.status_code == 422 and r.json()["errors"][0]["loc"] == ["query", "limit"] def test_formatting(): assert format_value(53372.1, {"format": "currency", "unit_short": "US$"}) == "US$53.4k" assert format_value(1.23e12, {"format": "currency"}) == "US$1.2T" # default prefix assert format_value(45.3e9, {"format": "currency", "unit_short": "intl $"}) == "intl $45.3B" assert format_short(53372.1, {"format": "currency", "unit_short": "US$"}) == "53.4k" assert format_short(3.44, {"format": "percent"}) == "3.4" assert format_value(3.44, {"format": "percent", "precision": 1}) == "3.4 %" assert format_value(82.13, {"format": "years"}) == "82.1 yrs" assert format_value(5.234, {"format": "tonnes", "precision": 2}) == "5.23 t" assert format_value(3.2, {"format": "per_1000"}) == "3.2 per 1,000" assert format_value(1_234_567, {"format": "number"}) == "1.2M" assert format_value(812.4, {"format": "number", "precision": 0}) == "812" assert format_value(None, {"format": "number"}) == "—" assert format_value(float("nan"), {"format": "number"}) == "—" assert compact_number(-2_500_000) == "-2.5M" assert format_change(1.2, 30.0, {"format": "percent"}) == "+1.2 pts" assert format_change(-100.0, -3.4, {"format": "currency"}) == "−3.4 %" def test_registry_caches_cleared_on_snapshot_open(tmp_path: Path, fixture_db: Path): from countryatlas import registry registry.topics() assert registry.topics.cache_info().currsize == 1 invalidate_registry_caches() assert registry.topics.cache_info().currsize == 0 and registry.indicators.cache_info().currsize == 0 # opening a new snapshot (new run_id) clears the loaders again registry.topics() db_path = tmp_path / "atlas.duckdb" shutil.copy(fixture_db, db_path) app = create_app(db_path, rate_limit_per_minute=0, cache=ResponseCache()) with TestClient(app) as c: assert c.get("/api/v1/health").json()["status"] == "ok" assert registry.topics.cache_info().currsize == 0 def test_source_urls(): assert source_url("worldbank", "WDI", "NY.GDP.PCAP.CD", "CA") == "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA" assert source_url("owid", "grapher", "median-age") == "https://ourworldindata.org/grapher/median-age" assert source_url("owid", "energy", "renewables_share_elec") == "https://github.com/owid/energy-data" assert source_url("eurostat", "prc_hpi_a", "") == "https://ec.europa.eu/eurostat/databrowser/view/prc_hpi_a/default/table" assert source_url("who", "GHO", "WHOSIS_000001") == "https://www.who.int/data/gho/data/indicators/indicator-details/GHO/WHOSIS_000001" assert source_url("fred", "", "UNRATE") == "https://fred.stlouisfed.org/series/UNRATE" assert source_url("imf", "WEO", "NGDPD") == "https://data.imf.org/" assert source_url("bis", "", "") == "https://data.bis.org/" and source_url("ilo", "", "") == "https://ilostat.ilo.org/" assert source_url("oecd", "X", "Y") == "https://data-explorer.oecd.org/" assert source_url("unknown", "", "") is None