""" Tests for Stage 45 — GET /tenants/{tid}/runs/preview. HTTP shell over the Stage 37 service method. The contract: - tenant key (readonly) or admin can call - cross-tenant probe → 403 - vertical is inferred from the tenant's registration - the route never writes to the runs/run_issues/run_decisions tables — that's the whole point of preview Tests verify auth, the inference, the underlying no-write contract (smoking-gun via row counts), and the error paths. """ from datetime import datetime, timedelta, timezone import pytest pytest.importorskip("fastapi") pytest.importorskip("httpx") pytest.importorskip("yaml") from fastapi.testclient import TestClient from infra import OrgStateService from infra.api import create_app def _seed_calibrated_tenant(dbfile, tenant_id="acme"): """Register a logistics tenant + push 20 days of observations + calibrate. Preview can then run end-to-end on stored state.""" from verticals import get_vertical_config svc = OrgStateService(dbfile) try: svc.register_tenant(tenant_id, tenant_id, vertical="logistics") cfg = get_vertical_config("logistics").entity_type("warehouse") base = datetime(2026, 1, 1, tzinfo=timezone.utc) rows = [] for d in range(20): for eid in ("wh_a", "wh_b"): values = {m.name: 100.0 + d for m in cfg.metrics} rows.append({ "entity_id": eid, "day": (base + timedelta(days=d)).date().isoformat(), "values": values, }) svc.observations.upsert_batch(tenant_id, "warehouse", rows) svc.calibrate_and_store(tenant_id, [ __import__("core").Observation( entity_id=r["entity_id"], day=r["day"], values=r["values"], ) for r in rows ], cfg, vertical="logistics") finally: svc.close() def _bootstrap(tmp_path): dbfile = str(tmp_path / "preview_http.sqlite3") _seed_calibrated_tenant(dbfile, "acme") _seed_calibrated_tenant(dbfile, "globex") svc = OrgStateService(dbfile) try: keys = { "acme_ro": svc.create_api_key("acme", role="readonly").raw, "acme_op": svc.create_api_key("acme", role="operator").raw, "globex_ro": svc.create_api_key("globex", role="readonly").raw, "admin": svc.create_admin_key().raw, } finally: svc.close() return dbfile, keys def _client(dbfile): return TestClient(create_app(dbfile)) def _auth(key): return {"Authorization": f"Bearer {key}"} # --- happy path ------------------------------------------------------- def test_owner_can_preview_with_readonly_key(tmp_path): """The whole point — a customer SDK with the tenant readonly key asks 'what would the next run say' and gets the summary.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.get("/tenants/acme/runs/preview?entity_type=warehouse", headers=_auth(keys["acme_ro"])) assert r.status_code == 200 body = r.json() assert body["tenant_id"] == "acme" assert body["entity_type"] == "warehouse" assert body["vertical"] == "logistics" assert "preview" in body p = body["preview"] assert {"n_states", "n_issues", "n_decisions", "top_issues"} <= p.keys() def test_admin_can_preview_any_tenant(tmp_path): dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.get("/tenants/globex/runs/preview?entity_type=warehouse", headers=_auth(keys["admin"])) assert r.status_code == 200 # --- vertical inference ---------------------------------------------- def test_vertical_inferred_from_tenant_registration(tmp_path): """The customer SDK doesn't pass vertical — it's taken from the tenant row. Regression: if a tenant on a different vertical were registered, preview would resolve THEIR config, not 'logistics'.""" dbfile, keys = _bootstrap(tmp_path) # acme is logistics → warehouse is a valid entity_type client = _client(dbfile) r = client.get("/tenants/acme/runs/preview?entity_type=warehouse", headers=_auth(keys["acme_ro"])) assert r.status_code == 200 assert r.json()["vertical"] == "logistics" # --- THE central contract: no DB writes ------------------------------ def test_preview_endpoint_does_not_write_a_run_row(tmp_path): """The HTTP route inherits Stage 37's no-persist contract. If the `runs` count changes after a preview call, the whole point of the feature is gone.""" dbfile, keys = _bootstrap(tmp_path) svc = OrgStateService(dbfile) try: before = svc.db.query_one("SELECT COUNT(*) AS n FROM runs")["n"] finally: svc.close() client = _client(dbfile) r = client.get("/tenants/acme/runs/preview?entity_type=warehouse", headers=_auth(keys["acme_ro"])) assert r.status_code == 200 svc = OrgStateService(dbfile) try: after = svc.db.query_one("SELECT COUNT(*) AS n FROM runs")["n"] finally: svc.close() assert before == after, ( f"preview wrote a run row (before={before}, after={after}) " "— the no-persist contract is broken" ) # --- auth ------------------------------------------------------------- def test_no_key_returns_401(tmp_path): dbfile, _ = _bootstrap(tmp_path) client = _client(dbfile) r = client.get("/tenants/acme/runs/preview?entity_type=warehouse") assert r.status_code == 401 def test_cross_tenant_returns_403(tmp_path): """An acme key probing globex's preview — the standard 403.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.get("/tenants/globex/runs/preview?entity_type=warehouse", headers=_auth(keys["acme_ro"])) assert r.status_code == 403 # --- error paths ------------------------------------------------------ def test_missing_entity_type_query_returns_422(tmp_path): """FastAPI's Query(...) (required) surfaces missing params as 422.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.get("/tenants/acme/runs/preview", headers=_auth(keys["acme_ro"])) assert r.status_code == 422 def test_unknown_entity_type_returns_404(tmp_path): """Wrong entity_type for the vertical → 404 via _resolve_config.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.get("/tenants/acme/runs/preview?entity_type=ghost", headers=_auth(keys["acme_ro"])) assert r.status_code == 404 def test_unknown_tenant_returns_403_via_auth(tmp_path): """A ghost tenant probed by a valid key from another tenant — require_tenant_or_admin's 403, not 404 (no enumeration).""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.get("/tenants/ghost/runs/preview?entity_type=warehouse", headers=_auth(keys["acme_ro"])) assert r.status_code == 403