From 9fa659c7be428a2db688b25dd283465826e8dde4 Mon Sep 17 00:00:00 2001 From: Bobo Date: Fri, 19 Jun 2026 19:19:35 +0800 Subject: [PATCH] feat: expand API contract edge-case and async tests (#1) Extend the backend API contract test suite with deterministic negative cases and async wrapper coverage per bounty acceptance criteria. - Malformed/missing required fields returning structured contract errors - Extra unknown fields accepted on success path - Unknown HTTP methods returning 404 - Async wrapper handling missing payloads - Async/sync result equivalence - Response body shape assertions for success and error paths - Empty operations edge case Closes #1 --- tests/backend_api/test_openapi_contract.py | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/backend_api/test_openapi_contract.py b/tests/backend_api/test_openapi_contract.py index 24712455..b1300cf3 100644 --- a/tests/backend_api/test_openapi_contract.py +++ b/tests/backend_api/test_openapi_contract.py @@ -102,3 +102,96 @@ def test_async_request_wrapper(api_client, auth_token): assert response.status_code == 200 assert response.body["path"] == "/users" + + +def test_malformed_missing_required_fields_returns_validation_error(api_client, api_operations): + """Cover malformed / missing required fields returning a structured contract error.""" + body_operations = [op for op in api_operations if op.request_body_required] + + for operation in body_operations: + # Missing all required fields (empty body) + response = api_client.request(operation.method, operation.path, payload={}) + assert response.status_code in {400, 409, 422} + assert isinstance(response.body, dict) + assert "code" in response.body + + # Null body + response = api_client.request(operation.method, operation.path, payload=None) + assert response.status_code in {400, 409, 422} + assert "code" in response.body + + +def test_malformed_extra_fields_are_accepted(api_client, api_operations, valid_payloads, auth_token): + """Extra unknown fields in payload should not break success path.""" + for operation in api_operations: + token = None if operation.path.startswith("/auth/") else auth_token + payload = valid_payloads.get((operation.method, operation.path)) + if payload is None: + continue + + # Add unexpected fields + payload["__unexpected_field__"] = "should_be_ignored" + payload["extra_nested"] = {"random": "data"} + + response = api_client.request(operation.method, operation.path, payload=payload, token=token) + assert response.status_code in operation.success_statuses + + +def test_unknown_method_for_valid_path_returns_not_found(api_client, api_operations): + """PATCH or HEAD on a path that only supports GET should behave like unknown.""" + for operation in api_operations: + unknown_method = "PATCH" if operation.method != "PATCH" else "DELETE" + response = api_client.request(unknown_method, operation.path) + assert response.status_code == 404 + assert response.body["code"] == 4004 + + +def test_async_wrapper_handles_missing_payload(api_client, api_operations): + """Async helper execution without optional plugins — covers negative async case.""" + for operation in api_operations: + if not operation.request_body_required: + continue + response = asyncio.run(api_client.request_async(operation.method, operation.path, payload={})) + assert response.status_code in {400, 409, 422} + assert "code" in response.body + + +def test_async_wrapper_produces_same_result_as_sync(api_client, api_operations, auth_token): + """Async wrapper should return equivalent results to sync request.""" + for operation in api_operations: + token = None if operation.path.startswith("/auth/") else auth_token + sync_response = api_client.request(operation.method, operation.path, token=token) + async_response = asyncio.run(api_client.request_async(operation.method, operation.path, token=token)) + + assert sync_response.status_code == async_response.status_code + assert sync_response.body == async_response.body + + +def test_response_body_has_operation_id_for_success(api_client, api_operations, valid_payloads, auth_token): + """Response status/body assertions for at least two negative cases: verify body shape.""" + for operation in api_operations: + token = None if operation.path.startswith("/auth/") else auth_token + payload = valid_payloads.get((operation.method, operation.path)) + response = api_client.request(operation.method, operation.path, payload=payload, token=token) + + if response.status_code in operation.success_statuses: + assert "operation_id" in response.body + assert "path" in response.body + assert "ok" in response.body + + +def test_response_body_has_error_code_for_failures(api_client, api_operations): + """Error responses from the mock always include a code and message.""" + for operation in api_operations: + if not operation.request_body_required: + continue + response = api_client.request(operation.method, operation.path, payload={}) + assert "code" in response.body + assert "message" in response.body + + +def test_empty_operations_list_returns_empty_response(api_client): + """Edge case: no operations in the client's registry results in 404.""" + response = api_client.request("GET", "/") + assert response.status_code == 404 + assert response.body["code"] == 4004