Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions backend/api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,18 @@ def request(
status_code = _first_available(operation.responses, (401, 403), default=400)
return MockResponse(status_code, {"code": 4002, "message": "Authentication required"})

if operation.request_body_required and not payload:
if operation.request_body_required:
status_code = _first_available(operation.responses, (422, 400, 409), default=422)
return MockResponse(status_code, {"code": 4001, "message": "Invalid request parameters"})
if payload is None:
return _contract_error(
status_code,
"Invalid request parameters",
{"body": "required"},
)

validation_error = _payload_validation_error(self.spec, operation, payload)
if validation_error:
return _contract_error(status_code, "Invalid request parameters", validation_error)

status_code = min(operation.success_statuses or (200,))
return MockResponse(
Expand Down Expand Up @@ -236,6 +245,57 @@ def _first_available(responses: tuple[int, ...], candidates: tuple[int, ...], *,
return default


def _contract_error(status_code: int, message: str, details: Mapping[str, Any]) -> MockResponse:
return MockResponse(status_code, {"code": 4001, "message": message, "details": details})


def _payload_validation_error(
spec: Mapping[str, Any],
operation: ApiOperation,
payload: Mapping[str, Any],
) -> dict[str, Any] | None:
missing = [field for field in operation.required_fields if field not in payload]
if missing:
return {"missing": missing}

schema = _request_schema(spec, operation.path, operation.method)
properties = schema.get("properties", {}) if isinstance(schema, Mapping) else {}
if not isinstance(properties, Mapping):
return None

malformed = []
for field, value in payload.items():
field_schema = properties.get(field)
if not isinstance(field_schema, Mapping):
continue
expected_type = field_schema.get("type")
if expected_type and not _matches_json_schema_type(value, expected_type):
malformed.append({"field": field, "expected": expected_type})

return {"malformed": malformed} if malformed else None


def _matches_json_schema_type(value: Any, expected_type: Any) -> bool:
if isinstance(expected_type, list):
return any(_matches_json_schema_type(value, item) for item in expected_type)

if expected_type == "boolean":
return isinstance(value, bool)
if expected_type == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if expected_type == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected_type == "array":
return isinstance(value, list)
if expected_type == "object":
return isinstance(value, Mapping)
if expected_type == "string":
return isinstance(value, str)
if expected_type == "null":
return value is None
return True


def _sample_value(field: str, schema: Any) -> Any:
if not isinstance(schema, Mapping):
return f"test-{field}"
Expand Down
24 changes: 24 additions & 0 deletions diagnostic/build-011bb3d4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"generated_at": "2026-06-24T00:50:10.823352+00:00",
"commit": "011bb3d4",
"diagnostic_logd": null,
"diagnostic_logd_error": "bd6ff8893eceea834ca2",
"message_blocker": "You need to fix your environment so encryptly runs before building.",
"chunked": false,
"chunk_size_bytes": null,
"password": null,
"decrypt_command": null,
"total_modules": 1,
"passed": 0,
"failed": 1,
"modules": [
{
"name": "encryptly-preflight",
"status": "FAIL",
"elapsed_seconds": 21.969,
"artifact": null,
"output": "You need to fix your environment so encryptly runs before building. eee72abd5b4ef31cecbf"
}
],
"pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging."
}
8 changes: 7 additions & 1 deletion tests/backend_api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ Coverage includes:
- one success-path assertion for every documented GET and POST operation
- authentication, not-found, request-validation, and internal-error cases
- empty, large, and unicode payload edge cases
- async request handling through pytest-asyncio
- async request handling through the standard library, without optional pytest plugins

Run the focused suite locally with:

```bash
python3 -m pytest -q tests/backend_api
```
36 changes: 36 additions & 0 deletions tests/backend_api/test_openapi_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,42 @@ def test_missing_required_payload_returns_validation_error(api_client, api_opera

assert response.status_code in {400, 409, 422}
assert response.body["code"] == 4001
assert response.body["details"]["body"] == "required"


def test_missing_required_field_returns_structured_contract_error(api_client):
response = api_client.request(
"POST",
"/auth/login",
payload={"email": "user@example.com"},
)

assert response.status_code in {400, 409, 422}
assert response.body["code"] == 4001
assert response.body["message"] == "Invalid request parameters"
assert response.body["details"] == {"missing": ["password"]}


def test_malformed_field_type_returns_structured_contract_error(api_client):
response = api_client.request(
"POST",
"/auth/login",
payload={"email": "user@example.com", "password": "secret", "remember_me": "yes"},
)

assert response.status_code in {400, 409, 422}
assert response.body["code"] == 4001
assert response.body["details"] == {"malformed": [{"field": "remember_me", "expected": "boolean"}]}


def test_async_request_wrapper_returns_negative_contract_response(api_client):
response = asyncio.run(
api_client.request_async("POST", "/auth/login", payload={"email": "user@example.com"})
)

assert response.status_code in {400, 409, 422}
assert response.body["code"] == 4001
assert response.body["details"]["missing"] == ["password"]


def test_internal_error_path_is_mocked_without_external_dependencies(api_client):
Expand Down