""" Tests for Stage 46 — `GET /admin/ops-summary` + service refactor. Two layers: - svc.ops_summary() — the pure method now shared by CLI + HTTP. The Stage 44 CLI tests already exercise it via the CLI path; here we test it directly so a future CLI-removal wouldn't lose coverage. - GET /admin/ops-summary — admin-only HTTP route, same shape. """ import json import os from datetime import datetime, timedelta, timezone import pytest from infra import OrgStateService # --- service-level direct tests -------------------------------------- @pytest.fixture def svc(): s = OrgStateService(":memory:") yield s s.close() def test_ops_summary_empty_platform(svc): """A fresh DB returns zeros for every count plus the schema/db metadata. Total invariant: tenants.total == healthy + unhealthy.""" out = svc.ops_summary() assert out["tenants"]["total"] == 0 assert out["tenants"]["healthy"] == 0 assert out["tenants"]["unhealthy"] == 0 assert out["schedules"]["total"] == 0 assert out["runs"]["total_all_time"] == 0 assert out["runs"]["last_24h"] == 0 assert out["calibrations"] == {"total": 0, "stale": 0} assert out["locks_held"] == 0 assert out["audit_log_entries"] == 0 assert out["thresholds"] == {"max_age_days": 90.0, "max_gap_hours": 48.0} def test_ops_summary_thresholds_propagate(svc): """Custom thresholds change the stale count without changing anything else.""" svc.register_tenant("acme", "ACME") now = datetime.now(timezone.utc) svc.db.execute( """INSERT INTO calibrations (tenant_id, entity_type, vertical, calibration_json, sample_size, derived_at) VALUES ('acme', 'warehouse', 'logistics', '{}', 42, ?)""", ((now - timedelta(days=200)).isoformat(),), ) svc.db.execute( """INSERT INTO runs (run_id, tenant_id, entity_type, vertical, status, n_states, n_issues, n_decisions, summary_json, started_at, finished_at) VALUES ('r1', 'acme', 'warehouse', 'logistics', 'completed', 0, 0, 0, '{}', ?, ?)""", ((now - timedelta(hours=5)).isoformat(), (now - timedelta(hours=5)).isoformat()), ) tight = svc.ops_summary() loose = svc.ops_summary(max_age_days=365) assert tight["calibrations"]["stale"] == 1 assert loose["calibrations"]["stale"] == 0 assert loose["thresholds"]["max_age_days"] == 365 def test_ops_summary_tenant_total_invariant(svc): """tenants.total == tenants.healthy + tenants.unhealthy, always.""" for tid in ("a", "b", "c"): svc.register_tenant(tid, tid) now = datetime.now(timezone.utc) svc.db.execute( """INSERT INTO calibrations (tenant_id, entity_type, vertical, calibration_json, sample_size, derived_at) VALUES ('a', 'warehouse', 'logistics', '{}', 42, ?)""", ((now - timedelta(days=200)).isoformat(),), ) out = svc.ops_summary() assert out["tenants"]["total"] == \ out["tenants"]["healthy"] + out["tenants"]["unhealthy"] # --- HTTP route smoke ------------------------------------------------- def _client(dbfile=":memory:"): pytest.importorskip("fastapi") pytest.importorskip("httpx") from fastapi.testclient import TestClient from infra.api import create_app return TestClient(create_app(dbfile)) def test_admin_ops_summary_returns_200_with_admin_key(tmp_path): dbfile = str(tmp_path / "admin_ops.sqlite3") boot = OrgStateService(dbfile) try: boot.register_tenant("acme", "ACME") admin = boot.create_admin_key() finally: boot.close() client = _client(dbfile) r = client.get("/admin/ops-summary", headers={"Authorization": f"Bearer {admin.raw}"}) assert r.status_code == 200 body = r.json() # all the same top-level keys the CLI emits assert set(body) >= {"schema_version", "db_backend", "tenants", "schedules", "runs", "calibrations", "locks_held", "audit_log_entries", "thresholds"} assert body["tenants"]["total"] == 1 def test_admin_ops_summary_401_without_key(tmp_path): """Once an admin key is configured, the route locks. Same pattern as the other /admin/* routes.""" dbfile = str(tmp_path / "noauth.sqlite3") OrgStateService(dbfile).close() os.environ["ORGSTATE_ADMIN_KEY"] = "admin_secret" try: client = _client(dbfile) r = client.get("/admin/ops-summary") assert r.status_code == 401 finally: os.environ.pop("ORGSTATE_ADMIN_KEY", None) def test_admin_ops_summary_with_tenant_key_is_forbidden(tmp_path): """A tenant key — even role=admin on its own tenant — must NOT be able to call this route. ops-summary enumerates the platform.""" dbfile = str(tmp_path / "tenant_key.sqlite3") boot = OrgStateService(dbfile) try: boot.register_tenant("acme", "ACME") boot.create_admin_key() # configures admin auth tk = boot.create_api_key("acme", role="admin") finally: boot.close() client = _client(dbfile) r = client.get("/admin/ops-summary", headers={"Authorization": f"Bearer {tk.raw}"}) assert r.status_code in (401, 403) def test_admin_ops_summary_query_params_propagate(tmp_path): dbfile = str(tmp_path / "qp.sqlite3") boot = OrgStateService(dbfile) try: boot.register_tenant("acme", "ACME") now = datetime.now(timezone.utc) boot.db.execute( """INSERT INTO calibrations (tenant_id, entity_type, vertical, calibration_json, sample_size, derived_at) VALUES ('acme', 'warehouse', 'logistics', '{}', 42, ?)""", ((now - timedelta(days=200)).isoformat(),), ) admin = boot.create_admin_key() finally: boot.close() client = _client(dbfile) # default 90d: stale r = client.get("/admin/ops-summary", headers={"Authorization": f"Bearer {admin.raw}"}) assert r.json()["calibrations"]["stale"] == 1 # loose threshold: not stale r = client.get("/admin/ops-summary?max_age_days=365", headers={"Authorization": f"Bearer {admin.raw}"}) assert r.json()["calibrations"]["stale"] == 0 assert r.json()["thresholds"]["max_age_days"] == 365 # --- CLI/HTTP parity -------------------------------------------------- def test_cli_and_http_emit_identical_payloads(tmp_path): """The whole point of the Stage 46 refactor: same numbers on both surfaces, always. If they ever diverge, this test fires.""" from infra.cli import main dbfile = str(tmp_path / "parity.sqlite3") main(["--db", dbfile, "tenant", "register", "acme", "ACME"]) boot = OrgStateService(dbfile) try: admin = boot.create_admin_key() finally: boot.close() # invoke CLI; capture stdout import contextlib import io buf = io.StringIO() with contextlib.redirect_stdout(buf): main(["--db", dbfile, "ops-summary"]) # the CLI tail (the LAST JSON object on stdout) is the payload — # earlier output is the tenant-register row from above chunks = buf.getvalue().strip().split("\n}\n") cli_payload = json.loads(chunks[-1] if chunks[-1].startswith("{") else chunks[-1] + "\n}") client = _client(dbfile) http_payload = client.get( "/admin/ops-summary", headers={"Authorization": f"Bearer {admin.raw}"}, ).json() # the dynamic fields (audit count goes up between calls; runs.last_24h # uses wall-clock now) make exact-equality flaky, so compare the # stable structural keys for k in ("tenants", "schedules", "calibrations", "locks_held", "thresholds", "schema_version", "db_backend"): assert cli_payload[k] == http_payload[k], ( f"CLI and HTTP disagree on {k!r}: " f"{cli_payload[k]!r} vs {http_payload[k]!r}" )