| """ |
| 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", [])) |
|
|
|
|
| |
|
|
| 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"}) |
| |
| |
| |
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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" |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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={}, |
| ) |
| |
| |
| assert r.status_code != 422 |
|
|
|
|
| |
|
|
| 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"}, |
| ) |
| |
| |
| assert r.status_code != 422 |
|
|
|
|
| |
|
|
| 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 |
| |
| 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"]] |
| |
| |
| 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}" |
|
|