| """ |
| 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}"} |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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) |
| |
| 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" |
|
|
|
|
| |
|
|
| 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" |
| ) |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|