""" Tests for Stage 78 — Pydantic request body validation. Before Stage 78, every POST/PUT route did ad-hoc dict.get() checks in the handler. Wrong-TYPE fields hit the handler and either crashed with 500 or coerced silently. Now bodies validate at the API boundary with proper 422 + field-level errors. This file tests the VALIDATION layer specifically — that 422 fires on the right shapes, with the right field paths in the detail. The happy-path behavior is covered by the existing per-route test files (test_infra_api_webhooks.py, schedules, admin_actions, etc.). The contract (FastAPI default): - 422 status code - body shape: {"detail": [{"loc": [...], "msg": ..., "type": ...}, ...]} - "loc" path includes "body" then the field name """ import pytest pytest.importorskip("fastapi") pytest.importorskip("httpx") from fastapi.testclient import TestClient from infra import OrgStateService from infra.api import create_app def _bootstrap(tmp_path): dbfile = str(tmp_path / "pyd.sqlite3") svc = OrgStateService(dbfile) try: svc.register_tenant("acme", "ACME") keys = { "admin": svc.create_admin_key().raw, "acme_op": svc.create_api_key("acme", role="operator").raw, "acme_admin": svc.create_api_key("acme", role="admin").raw, } finally: svc.close() return dbfile, keys def _client(dbfile): return TestClient(create_app(dbfile)) def _auth(k): return {"Authorization": f"Bearer {k}"} def _field_in_errors(body: dict, field: str) -> bool: """Helper: does any error in body['detail'] reference `field`?""" return any(field in err.get("loc", []) for err in body.get("detail", [])) # --- TenantRegisterBody --------------------------------------------- def test_tenants_post_missing_tenant_id_422(tmp_path): dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post("/tenants", headers=_auth(keys["admin"]), json={"name": "X"}) assert r.status_code == 422 assert _field_in_errors(r.json(), "tenant_id") def test_tenants_post_empty_tenant_id_422(tmp_path): """min_length=1 catches empty string.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post("/tenants", headers=_auth(keys["admin"]), json={"tenant_id": "", "name": "X"}) assert r.status_code == 422 def test_tenants_post_wrong_type_422(tmp_path): """tenant_id must be str, not int.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post("/tenants", headers=_auth(keys["admin"]), json={"tenant_id": 12345, "name": "X"}) # pydantic strict mode would 422; lax mode coerces. We're in # default mode which coerces int->str. Either is acceptable # here — what we DO want is no crash and a 2xx or 422. assert r.status_code in (201, 422) def test_tenants_post_default_vertical_filled(tmp_path): """Omitting `vertical` shouldn't reject — schema has default 'logistics'.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post("/tenants", headers=_auth(keys["admin"]), json={"tenant_id": "new", "name": "New"}) assert r.status_code == 201 # --- ScheduleCreateBody --------------------------------------------- def test_schedule_bad_frequency_caught_by_model(tmp_path): """model_post_init validates frequency ∈ {hourly,daily,weekly}.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/schedules", headers=_auth(keys["acme_op"]), json={"vertical": "logistics", "entity_type": "warehouse", "connector_type": "csv_folder", "frequency": "centennial"}, ) assert r.status_code == 422 def test_schedule_enabled_must_be_bool(tmp_path): """`enabled` is typed bool; a string '1' is rejected (str-coerce is disabled for bool fields in Pydantic v2 by default for safety).""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/schedules", headers=_auth(keys["acme_op"]), json={"vertical": "logistics", "entity_type": "warehouse", "connector_type": "csv_folder", "enabled": "yes-please"}, ) assert r.status_code == 422 assert _field_in_errors(r.json(), "enabled") def test_schedule_connector_config_must_be_object(tmp_path): """connector_config: Dict[str, Any] — a list is rejected.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/schedules", headers=_auth(keys["acme_op"]), json={"vertical": "logistics", "entity_type": "warehouse", "connector_type": "csv_folder", "connector_config": ["this", "should", "be", "an", "object"]}, ) assert r.status_code == 422 # --- ApiKeyMintBody -------------------------------------------------- def test_api_key_invalid_role_422(tmp_path): """model_post_init validates role ∈ {admin,operator,readonly}.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/api_keys", headers=_auth(keys["acme_admin"]), json={"role": "superuser"}, ) assert r.status_code == 422 def test_api_key_typo_in_role_caught(tmp_path): """The user-facing win: 'oprator' (typo) → 422, not silently minting an admin key (the old default).""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/api_keys", headers=_auth(keys["acme_admin"]), json={"role": "oprator"}, ) assert r.status_code == 422 def test_api_key_defaults_when_body_empty(tmp_path): """Empty body → default role='admin', name=''. No validation error — these defaults exist for the bootstrap workflow.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/api_keys", headers=_auth(keys["acme_admin"]), json={}, ) assert r.status_code == 201 assert r.json()["role"] == "admin" # --- WebhookCreateBody ---------------------------------------------- def test_webhook_url_required_422(tmp_path): dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post("/tenants/acme/webhooks", headers=_auth(keys["acme_op"]), json={}) assert r.status_code == 422 assert _field_in_errors(r.json(), "url") def test_webhook_secret_optional(tmp_path): """Omitting secret is valid — webhook fires unsigned.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post("/tenants/acme/webhooks", headers=_auth(keys["acme_op"]), json={"url": "https://hooks.slack.com/x"}) assert r.status_code == 201 def test_webhook_secret_must_be_str_not_dict(tmp_path): """A common copy-paste mistake.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post("/tenants/acme/webhooks", headers=_auth(keys["acme_op"]), json={"url": "https://hooks.slack.com/x", "secret": {"value": "shh"}}) assert r.status_code == 422 # --- CalibrateBody / RunAnalysisBody -------------------------------- def test_calibrate_observations_empty_list_422(tmp_path): """min_length=1 on observations — empty list isn't calibratable.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/calibrations", headers=_auth(keys["acme_op"]), json={"vertical": "logistics", "entity_type": "warehouse", "observations": []}, ) assert r.status_code == 422 def test_calibrate_observation_missing_entity_id_422(tmp_path): """ObservationModel.entity_id required.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/calibrations", headers=_auth(keys["acme_op"]), json={"vertical": "logistics", "entity_type": "warehouse", "observations": [{"day": "2026-05-17", "values": {"orders": 100}}]}, ) assert r.status_code == 422 def test_calibrate_observation_values_must_be_object_not_list(tmp_path): """values: Dict[str, Any] — list rejected.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/calibrations", headers=_auth(keys["acme_op"]), json={"vertical": "logistics", "entity_type": "warehouse", "observations": [{"entity_id": "w1", "day": "2026-05-17", "values": [1, 2, 3]}]}, ) assert r.status_code == 422 # --- TenantOverridesBody -------------------------------------------- def test_overrides_severity_must_be_object(tmp_path): """Already covered in overrides test file, replicated here for symmetry with the Stage 78 grouping.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.put( "/admin/tenants/acme/overrides/warehouse", headers=_auth(keys["admin"]), json={"severity_thresholds": 42}, ) assert r.status_code == 422 def test_overrides_drift_weights_must_be_numbers(tmp_path): """Dict[str, float] catches string values.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.put( "/admin/tenants/acme/overrides/warehouse", headers=_auth(keys["admin"]), json={"drift_weights": {"delta": "not-a-number"}}, ) assert r.status_code == 422 def test_overrides_all_optional_empty_body_ok(tmp_path): """Both fields optional → empty body should be accepted by Pydantic. Service-layer may complain but not the schema.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.put( "/admin/tenants/acme/overrides/warehouse", headers=_auth(keys["admin"]), json={}, ) # service-layer may still 400 (e.g. "no overrides to apply"), # but NOT a Pydantic 422 assert r.status_code != 422 # --- AdminRecalibrateBody ------------------------------------------- def test_recalibrate_min_observations_must_be_positive(tmp_path): """ge=1 on min_observations.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/admin/tenants/acme/recalibrate", headers=_auth(keys["admin"]), json={"entity_type": "warehouse", "min_observations": 0}, ) assert r.status_code == 422 def test_recalibrate_min_observations_default_used(tmp_path): """Default 14 applied if omitted; not a validation error.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/admin/tenants/acme/recalibrate", headers=_auth(keys["admin"]), json={"entity_type": "warehouse"}, ) # service may 400 (no observations to recalibrate from) but # NOT 422 — defaults applied successfully assert r.status_code != 422 # --- error response shape -------------------------------------------- def test_422_response_shape_has_loc_and_msg(tmp_path): """Verify the field-level error contract: every error has a `loc` path and a `msg`. This is what enables a customer dashboard to display 'url is required' next to the right input field.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post("/tenants/acme/webhooks", headers=_auth(keys["acme_op"]), json={}) assert r.status_code == 422 body = r.json() assert "detail" in body assert isinstance(body["detail"], list) assert len(body["detail"]) >= 1 err = body["detail"][0] assert "loc" in err assert "msg" in err # loc should mention the field path so UIs can route the error assert any("url" in part for part in err["loc"] if isinstance(part, str)) def test_422_field_level_errors_multiple(tmp_path): """Two missing fields → two errors, one per field. Customer can fix everything in one round-trip instead of one-at-a-time.""" dbfile, keys = _bootstrap(tmp_path) client = _client(dbfile) r = client.post( "/tenants/acme/schedules", headers=_auth(keys["acme_op"]), json={}, ) assert r.status_code == 422 body = r.json() field_paths = [err["loc"] for err in body["detail"]] # vertical + entity_type + connector_type all missing → at # least 3 errors assert len(body["detail"]) >= 3 flat = [p for loc in field_paths for p in loc] for required in ("vertical", "entity_type", "connector_type"): assert required in flat, f"expected error for {required}"