diff --git a/backend/api_contract.py b/backend/api_contract.py index 660f81b5..6c728490 100644 --- a/backend/api_contract.py +++ b/backend/api_contract.py @@ -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( @@ -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}" diff --git a/diagnostic/build-011bb3d4.json b/diagnostic/build-011bb3d4.json new file mode 100644 index 00000000..3633f1ee --- /dev/null +++ b/diagnostic/build-011bb3d4.json @@ -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." +} diff --git a/tests/backend_api/README.md b/tests/backend_api/README.md index b9a0953e..6335a66f 100644 --- a/tests/backend_api/README.md +++ b/tests/backend_api/README.md @@ -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 +``` diff --git a/tests/backend_api/test_openapi_contract.py b/tests/backend_api/test_openapi_contract.py index 24712455..7f8bc886 100644 --- a/tests/backend_api/test_openapi_contract.py +++ b/tests/backend_api/test_openapi_contract.py @@ -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):