""" Tests for Stage 86 — OpenAPI examples + SDK README. Coverage: 1. SDK README exists at the right path 2. README mentions the 3 public classes + auth + transport injection + Hebrew quickstart reference 3. OpenAPI schema includes the json_schema_extra examples for each tightened body (7 schemas total) 4. Examples in the schema are well-formed (parse as JSON) 5. /docs and /openapi.json endpoints work end-to-end """ from pathlib import Path import pytest pytest.importorskip("fastapi") pytest.importorskip("httpx") from fastapi.testclient import TestClient from infra import OrgStateService from infra.api import create_app from infra.api.schemas import ( ApiKeyMintBody, CalibrateBody, ObservationsIngestBody, RunAnalysisBody, ScheduleCreateBody, TenantRegisterBody, WebhookCreateBody, ) SDK_README = Path(__file__).parent.parent / "orgstate_client" / "README.md" # ========================================================= # SDK README — anchors # ========================================================= def test_sdk_readme_exists(): assert SDK_README.exists(), \ f"SDK README missing at {SDK_README}" def test_sdk_readme_mentions_public_classes(): text = SDK_README.read_text(encoding="utf-8") for cls in ("Client", "AdminClient", "ClientError"): assert cls in text, f"SDK README missing reference to {cls}" def test_sdk_readme_documents_transport_injection(): """transport injection is the key testability story — the README must show how to plug FastAPI's TestClient in.""" text = SDK_README.read_text(encoding="utf-8") assert "TestClient" in text assert "transport=" in text def test_sdk_readme_links_to_hebrew_quickstart_and_runbook(): """The README is part of the customer-facing doc bundle. Cross-link to the rest so navigation works in any tool.""" text = SDK_README.read_text(encoding="utf-8") assert "CUSTOMER_QUICKSTART_HE.md" in text assert "RUNBOOK.md" in text def test_sdk_readme_includes_error_handling_example(): text = SDK_README.read_text(encoding="utf-8") assert "ClientError" in text assert "status_code" in text assert "422" in text # Pydantic case from Stage 78 # ========================================================= # Pydantic schemas — json_schema_extra contains examples # ========================================================= @pytest.mark.parametrize("model,expected_field", [ (TenantRegisterBody, "tenant_id"), (CalibrateBody, "observations"), (RunAnalysisBody, "observations"), (ScheduleCreateBody, "connector_type"), (WebhookCreateBody, "url"), (ObservationsIngestBody, "entity_type"), (ApiKeyMintBody, "role"), ]) def test_model_has_example_in_schema(model, expected_field): """Each tightened body should expose an `examples` array in its JSON schema, and the example should mention the field that defines its purpose.""" schema = model.model_json_schema() examples = schema.get("examples", []) assert len(examples) >= 1, \ f"{model.__name__} missing examples in schema" example = examples[0] assert expected_field in example, \ f"{model.__name__} example missing field {expected_field!r}" def test_calibrate_example_observations_well_formed(): """The observations example must have the structure that ObservationModel requires (entity_id, day, values) — otherwise a customer copy/pasting from /docs would get a 422 back.""" schema = CalibrateBody.model_json_schema() example = schema["examples"][0] obs = example["observations"] assert isinstance(obs, list) and len(obs) >= 1 for o in obs: assert "entity_id" in o assert "day" in o assert "values" in o and isinstance(o["values"], dict) def test_example_actually_validates_against_the_model(): """The strongest contract — the example schema MUST parse against the model itself. Otherwise the /docs example is a lie that breaks the first customer who copies it.""" schema = CalibrateBody.model_json_schema() example = schema["examples"][0] # constructing the model from the example raises if invalid instance = CalibrateBody(**example) assert instance.vertical == "logistics" def test_webhook_example_validates(): example = WebhookCreateBody.model_json_schema()["examples"][0] WebhookCreateBody(**example) def test_api_key_example_validates(): example = ApiKeyMintBody.model_json_schema()["examples"][0] inst = ApiKeyMintBody(**example) assert inst.role == "operator" def test_schedule_example_validates(): example = ScheduleCreateBody.model_json_schema()["examples"][0] ScheduleCreateBody(**example) # ========================================================= # /openapi.json and /docs endpoints work end-to-end # ========================================================= def _bootstrap(tmp_path): dbfile = str(tmp_path / "openapi.sqlite3") svc = OrgStateService(dbfile) try: svc.register_tenant("acme", "ACME") finally: svc.close() return dbfile def test_openapi_json_endpoint_serves_spec(tmp_path): dbfile = _bootstrap(tmp_path) client = TestClient(create_app(dbfile)) r = client.get("/openapi.json") assert r.status_code == 200 spec = r.json() assert spec["info"]["title"] == "OrgState Engine API" assert "paths" in spec assert "components" in spec def test_openapi_includes_pydantic_schemas(tmp_path): """The schemas section must reference the body models we wired up in Stage 78 — verifies the schemas are in the spec, not just in the route handlers.""" dbfile = _bootstrap(tmp_path) client = TestClient(create_app(dbfile)) r = client.get("/openapi.json") schemas = r.json()["components"]["schemas"] for name in ("TenantRegisterBody", "WebhookCreateBody", "CalibrateBody", "ScheduleCreateBody"): assert name in schemas, f"OpenAPI missing schema {name}" def test_openapi_schemas_carry_examples(tmp_path): """Stage 86 added json_schema_extra examples to the 7 main body schemas. End-to-end verification that the examples make it into the served OpenAPI spec.""" dbfile = _bootstrap(tmp_path) client = TestClient(create_app(dbfile)) schemas = client.get("/openapi.json").json()["components"]["schemas"] for name in ("TenantRegisterBody", "WebhookCreateBody"): assert "examples" in schemas[name] or \ "example" in schemas[name], \ f"{name} has no example in served OpenAPI spec" def test_docs_html_endpoint_renders(tmp_path): """Swagger UI is auto-served by FastAPI at /docs. We don't parse the HTML — just verify it 200s and isn't blocked by our security_headers or rate_limit middlewares.""" dbfile = _bootstrap(tmp_path) client = TestClient(create_app(dbfile)) r = client.get("/docs") assert r.status_code == 200 assert "swagger" in r.text.lower() or "redoc" in r.text.lower() \ or "openapi" in r.text.lower()