"""API contract smoke tests against the live local database (docs/API.md). Read-only except for the `/views` beacon. Counts are never asserted as fixed numbers: connectors add data while the suite runs.""" from __future__ import annotations from collections.abc import AsyncIterator import pytest from httpx import ASGITransport, AsyncClient from aiatlas import db from aiatlas.api.main import app from aiatlas.config import settings from aiatlas.services import cache ADMIN = {"x-aia-admin-token": settings.admin_token or "dev-admin-token"} MODEL = "claude-opus-5" MODEL_B = "claude-sonnet-5" @pytest.fixture async def client() -> AsyncIterator[AsyncClient]: """pytest-asyncio runs each test in its own loop: pools must not outlive the loop that created them.""" await cache.cache_invalidate() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c await cache.close() await db.dispose() async def test_health(client: AsyncClient) -> None: for path in ("/health", "/api/v1/health"): r = await client.get(path) assert r.status_code == 200 body = r.json() assert body["status"] in ("ok", "degraded") and body["db"] is True assert {"version", "redis", "llm", "time"} <= set(body) async def test_stats_keys_and_live(client: AsyncClient) -> None: r = await client.get("/api/v1/stats") assert r.status_code == 200 s = r.json() for key in ("entities", "entities_total", "sources", "connectors", "documents", "snapshots", "claims", "claims_current", "relations", "change_events", "change_events_24h", "benchmark_results", "prices_current", "prices_total", "review_pending", "llm_jobs", "archive", "computed_at"): assert key in s, key assert s["entities_total"] == sum(s["entities"].values()) > 0 assert s["entities"].get("model", 0) > 0 for t in ("model", "company", "paper", "provider", "benchmark", "hardware", "framework", "dataset", "tool", "repository"): assert t in s["entities"], t # zero-filled for the homepage assert {"raw_bytes", "raw_files", "text_bytes", "text_files"} <= set(s["archive"]) hist = await client.get("/api/v1/stats/history?days=30") assert hist.status_code == 200 and "items" in hist.json() async def test_search_returns_claude_models(client: AsyncClient) -> None: r = await client.get("/api/v1/search", params={"q": "claude", "limit": 10}) assert r.status_code == 200 body = r.json() assert body["total"] > 0 and body["items"] assert body["query"]["text"] == "claude" assert all("rank" in it for it in body["items"]) assert any(it["entity_type"] == "model" and it["slug"].startswith("claude") for it in body["items"]) sug = await client.get("/api/v1/search/suggest", params={"q": "cla"}) assert sug.status_code == 200 and sug.json()["items"] assert {"id", "entity_type", "slug", "name", "organization_name"} <= set(sug.json()["items"][0]) assert (await client.get("/api/v1/search", params={"q": ""})).status_code == 400 async def test_models_filters(client: AsyncClient) -> None: r = await client.get("/api/v1/models", params={"openness": "proprietary", "limit": 200}) assert r.status_code == 200 body = r.json() assert {"items", "total", "limit", "offset"} <= set(body) and body["items"] assert all(it["attributes"].get("openness") == "proprietary" for it in body["items"]) r = await client.get("/api/v1/models", params={"min_context": 500000, "limit": 200}) assert r.status_code == 200 items = r.json()["items"] assert items and all(int(it["attributes"]["context_length"]) >= 500000 for it in items) r = await client.get("/api/v1/models", params={"facets": 1, "limit": 1}) assert r.status_code == 200 facets = r.json()["facets"] assert {"organizations", "openness", "modalities", "families", "years", "licenses", "status"} <= set(facets) assert any(o["slug"] == "anthropic" for o in facets["organizations"]) assert (await client.get("/api/v1/models", params={"sort": "nope"})).status_code == 400 assert (await client.get("/api/v1/models", params={"limit": 999})).status_code == 422 async def test_entity_detail_blocks(client: AsyncClient) -> None: r = await client.get(f"/api/v1/entities/{MODEL}") assert r.status_code == 200 d = r.json() assert d["slug"] == MODEL and d["entity_type"] == "model" for block in ("attributes", "provenance", "aliases", "identifiers", "relations", "sources", "timeline", "prices", "price_history", "results", "lineage", "providers", "quality", "counts"): assert block in d, block assert d["provenance"] and all({"tier", "confidence", "extractor", "observed_at"} <= set(v) for v in d["provenance"].values()) assert any(v.get("source_name") for v in d["provenance"].values() if v.get("source_id")) assert d["status"] in ("active", "preview", "deprecated", "retired", "announced", "limited-availability", "unknown") assert d["prices"] and d["prices"][0]["provider"]["slug"] and d["prices"][0]["input_per_mtok"] is not None assert d["timeline"] and any(e["event_type"] == "NEW_MODEL" for e in d["timeline"]) assert d["sources"] and {"url", "tier", "doc_type", "snapshots"} <= set(d["sources"][0]) assert d["organization"]["slug"] == "anthropic" assert {"ancestors", "descendants", "quantizations"} == set(d["lineage"]) # type-scoped aliases assert (await client.get(f"/api/v1/models/{MODEL}")).status_code == 200 assert (await client.get(f"/api/v1/companies/{MODEL}")).status_code == 404 assert (await client.get("/api/v1/entities/definitely-not-an-entity")).status_code == 404 # resolution by id by_id = await client.get(f"/api/v1/entities/{d['id']}") assert by_id.status_code == 200 and by_id.json()["slug"] == MODEL async def test_company_detail(client: AsyncClient) -> None: r = await client.get("/api/v1/companies/anthropic") assert r.status_code == 200 d = r.json() assert d["entity_type"] in ("company", "organization", "lab", "university") assert d["models"]["total"] > 0 and d["models"]["items"][0]["entity_type"] == "model" assert d["timeline"] # includes events of the models it develops async def test_entity_subresources(client: AsyncClient) -> None: tl = await client.get(f"/api/v1/entities/{MODEL}/timeline", params={"limit": 5}) assert tl.status_code == 200 and "items" in tl.json() hist = await client.get(f"/api/v1/entities/{MODEL}/history", params={"property": "context_length"}) assert hist.status_code == 200 and hist.json()["items"] and hist.json()["items"][0]["property"] == "context_length" graph = await client.get(f"/api/v1/entities/{MODEL}/graph", params={"depth": 1}) assert graph.status_code == 200 and graph.json()["nodes"] and "edges" in graph.json() src = await client.get(f"/api/v1/entities/{MODEL}/sources") assert src.status_code == 200 and src.json()["items"] rel = await client.get(f"/api/v1/entities/{MODEL}/related", params={"limit": 5}) assert rel.status_code == 200 and len(rel.json()["items"]) <= 5 async def test_asof(client: AsyncClient) -> None: r = await client.get(f"/api/v1/entities/{MODEL}/asof", params={"date": "2020-01-01"}) assert r.status_code == 200 body = r.json() assert body["existed"] is False and body["attributes"] == {} and body["claims"] == [] now = await client.get(f"/api/v1/entities/{MODEL}/asof", params={"date": "2999-12-31"}) assert now.status_code == 200 and now.json()["existed"] is True and now.json()["attributes"] assert (await client.get(f"/api/v1/entities/{MODEL}/asof", params={"date": "not-a-date"})).status_code == 400 async def test_changes_pagination(client: AsyncClient) -> None: first = await client.get("/api/v1/changes", params={"limit": 3}) assert first.status_code == 200 body = first.json() assert {"items", "total", "limit", "offset"} <= set(body) and len(body["items"]) == 3 and body["total"] >= 3 assert all(e["event_type"] != "DOCUMENT_CHANGED" for e in body["items"]) ev = body["items"][0] assert {"id", "event_type", "category", "summary", "importance", "observed_at", "entity", "meta"} <= set(ev) cursor = body["next_before"] assert cursor == body["items"][-1]["observed_at"] second = await client.get("/api/v1/changes", params={"limit": 3, "before": cursor}) assert second.status_code == 200 ids1 = {e["id"] for e in body["items"]} assert all(e["id"] not in ids1 for e in second.json()["items"]) assert all(e["observed_at"] < cursor for e in second.json()["items"]) daily = await client.get("/api/v1/changes/daily") assert daily.status_code == 200 and {"date", "counts", "sections", "new_models"} <= set(daily.json()) cats = await client.get("/api/v1/changes/categories", params={"days": 30}) assert cats.status_code == 200 and "items" in cats.json() async def test_compare(client: AsyncClient) -> None: r = await client.get("/api/v1/compare", params={"ids": f"{MODEL},{MODEL_B}"}) assert r.status_code == 200 body = r.json() assert body["entity_type"] == "model" and len(body["items"]) == 2 keys = {d["key"] for d in body["dimensions"]} assert {"parameter_count", "context_length", "openness", "release_date", "best_input_per_mtok"} <= keys for item in body["items"]: assert {"entity", "values", "provenance", "prices", "results"} <= set(item) assert item["values"]["context_length"] is not None assert (await client.get("/api/v1/compare", params={"ids": MODEL})).status_code == 400 assert (await client.get("/api/v1/compare", params={"ids": f"{MODEL},anthropic"})).status_code == 400 async def test_listings_and_misc(client: AsyncClient) -> None: for path in ("/api/v1/companies?facets=1", "/api/v1/papers", "/api/v1/providers", "/api/v1/prices", "/api/v1/prices/index?days=14", "/api/v1/benchmarks", "/api/v1/hardware", "/api/v1/hardware/fit?memory_gb=24", "/api/v1/explore/types", "/api/v1/explore/hardware", "/api/v1/timeline?limit=10", "/api/v1/diff?a=2026-01-01&b=2026-12-31", "/api/v1/sources", "/api/v1/methodology", "/api/v1/trending", "/api/v1/sitemap?limit=5", f"/api/v1/prices/history?model={MODEL}"): r = await client.get(path) assert r.status_code == 200, (path, r.text[:200]) prices = (await client.get("/api/v1/prices", params={"model": MODEL})).json() assert prices["items"] and prices["items"][0]["model"]["slug"] == MODEL meth = (await client.get("/api/v1/methodology")).json() assert meth["metrics"] and {"key", "label", "version", "description"} <= set(meth["metrics"][0]) assert all({"event_type", "label", "importance", "count"} <= set(t) for t in meth["event_types"]) assert next(t for t in meth["event_types"] if t["event_type"] == "NEW_MODEL")["label"] == "New model" srcs = (await client.get("/api/v1/sources")).json()["items"] linked = [c for s in srcs for c in s["connectors"]] assert linked and {"name", "label", "health", "last_success_at", "interval_seconds"} <= set(linked[0]) fit = (await client.get("/api/v1/hardware/fit", params={"memory_gb": 24})).json() assert fit["estimated"] is True and fit["assumptions"] and "items" in fit assert (await client.get("/api/v1/hardware/fit", params={"memory_gb": 24, "quant": "2bit"})).status_code == 400 assert (await client.get("/api/v1/explore/nonsense")).status_code == 404 assert (await client.get("/api/v1/api-keys/me")).status_code == 401 view = await client.post("/api/v1/views", json={"path": "/models/claude-opus-5"}) assert view.status_code == 200 and view.json() == {"ok": True} assert (await client.post("/api/v1/views", json={"path": "/x"})).status_code == 429 # 1 req/s/IP async def test_cache_roundtrip_is_stable(client: AsyncClient) -> None: a = (await client.get("/api/v1/models", params={"limit": 3})).json() b = (await client.get("/api/v1/models", params={"limit": 3})).json() assert a == b async def test_admin_auth(client: AsyncClient) -> None: assert (await client.get("/api/v1/admin/overview")).status_code == 401 assert (await client.get("/api/v1/admin/overview", headers={"x-aia-admin-token": "wrong"})).status_code == 401 r = await client.get("/api/v1/admin/overview", headers=ADMIN) assert r.status_code == 200 body = r.json() assert {"stats", "queue", "heartbeats", "connectors", "review_pending", "recent_errors", "llm", "archive"} <= set(body) saved = settings.admin_token settings.admin_token = "" try: assert (await client.get("/api/v1/admin/overview", headers=ADMIN)).status_code == 503 finally: settings.admin_token = saved async def test_merge_entities_rolled_back(client: AsyncClient) -> None: """Exercise the curation SQL end-to-end on live rows, then roll back — the database is left untouched.""" from aiatlas.db import engine, fetch_one, fetch_val from aiatlas.services.merge import merge_entities async with engine().connect() as conn: trans = await conn.begin() try: src = await fetch_one(conn, "select id from entities where slug = :s", s=MODEL_B) dst = await fetch_one(conn, "select id from entities where slug = :s", s=MODEL) assert src and dst res = await merge_entities(conn, src["id"], dst["id"]) assert res["target_id"] == dst["id"] and res["moved"]["claims"] > 0 merged = await fetch_one(conn, "select status, merged_into from entities where id = :id", id=src["id"]) assert merged["status"] == "merged" and merged["merged_into"] == dst["id"] assert await fetch_val(conn, "select count(*) from claims where entity_id = :id", id=src["id"]) == 0 assert await fetch_val(conn, "select count(*) from entity_aliases where entity_id = :t and kind = 'former_name'", t=dst["id"]) >= 1 with pytest.raises(ValueError): await merge_entities(conn, src["id"], dst["id"]) # already merged finally: await trans.rollback() async with engine().connect() as conn: assert (await fetch_one(conn, "select status from entities where slug = :s", s=MODEL_B))["status"] != "merged" async def test_admin_read_routes(client: AsyncClient) -> None: review = await client.get("/api/v1/admin/review", headers=ADMIN) assert review.status_code == 200 and {"items", "total", "by_kind"} <= set(review.json()) for path in ("/api/v1/admin/connectors", "/api/v1/admin/runs?limit=2", "/api/v1/admin/errors?limit=2", "/api/v1/admin/documents?limit=2", "/api/v1/admin/jobs", "/api/v1/admin/llm-jobs?limit=2", "/api/v1/admin/entities/duplicates?type=model", "/api/v1/admin/infrastructure"): r = await client.get(path, headers=ADMIN) assert r.status_code == 200, (path, r.text[:200]) docs = (await client.get("/api/v1/admin/documents?limit=1", headers=ADMIN)).json() if docs["items"]: doc = (await client.get(f"/api/v1/admin/documents/{docs['items'][0]['id']}", headers=ADMIN)).json() assert "snapshots" in doc if doc["snapshots"]: snap = (await client.get(f"/api/v1/admin/snapshots/{doc['snapshots'][0]['id']}", headers=ADMIN)).json() assert "raw_path" not in snap and "text_path" not in snap assert {"structured", "diff", "text", "claims"} <= set(snap) assert snap["text"] is None or len(snap["text"]) <= 20 * 1024 assert (await client.post("/api/v1/admin/connectors/does-not-exist/run", headers=ADMIN, json={})).status_code == 404 assert (await client.get("/api/v1/admin/review/nope", headers=ADMIN)).status_code in (404, 405)