File size: 7,109 Bytes
d2d1903
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
"""
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()