|
| 1 | +"""Property-based assertion library for MCP adapter testing. |
| 2 | +
|
| 3 | +All assertions verify adapter contracts using fields.py as the reference |
| 4 | +through HostSpec metadata. No hardcoded field names — everything is derived. |
| 5 | +
|
| 6 | +Usage: |
| 7 | + >>> from tests.test_data.mcp_adapters.assertions import assert_only_supported_fields |
| 8 | + >>> assert_only_supported_fields(result, host_spec) |
| 9 | +""" |
| 10 | + |
| 11 | +from typing import Any, Dict |
| 12 | + |
| 13 | +from hatch.mcp_host_config.fields import EXCLUDED_ALWAYS |
| 14 | + |
| 15 | +# Import locally to avoid circular; HostSpec is used as type hint only |
| 16 | +from tests.test_data.mcp_adapters.host_registry import HostSpec |
| 17 | + |
| 18 | + |
| 19 | +def assert_only_supported_fields(result: Dict[str, Any], host: HostSpec) -> None: |
| 20 | + """Verify result contains only fields from fields.py for this host. |
| 21 | +
|
| 22 | + After field mapping, the result may contain host-native names (e.g., |
| 23 | + Codex 'arguments' instead of 'args'). We account for this by also |
| 24 | + accepting mapped field names. |
| 25 | +
|
| 26 | + Args: |
| 27 | + result: Serialized adapter output |
| 28 | + host: HostSpec with metadata derived from fields.py |
| 29 | + """ |
| 30 | + result_fields = set(result.keys()) |
| 31 | + # Build the set of allowed field names: supported + mapped target names |
| 32 | + allowed = set(host.supported_fields) |
| 33 | + for _universal, host_specific in host.field_mappings.items(): |
| 34 | + allowed.add(host_specific) |
| 35 | + |
| 36 | + unsupported = result_fields - allowed |
| 37 | + assert not unsupported, ( |
| 38 | + f"[{host.host_name}] Unsupported fields in result: {sorted(unsupported)}. " |
| 39 | + f"Allowed: {sorted(allowed)}" |
| 40 | + ) |
| 41 | + |
| 42 | + |
| 43 | +def assert_excluded_fields_absent(result: Dict[str, Any], host: HostSpec) -> None: |
| 44 | + """Verify EXCLUDED_ALWAYS fields are not in result. |
| 45 | +
|
| 46 | + Args: |
| 47 | + result: Serialized adapter output |
| 48 | + host: HostSpec (used for error context) |
| 49 | + """ |
| 50 | + excluded_present = set(result.keys()) & EXCLUDED_ALWAYS |
| 51 | + assert ( |
| 52 | + not excluded_present |
| 53 | + ), f"[{host.host_name}] Excluded fields found in result: {sorted(excluded_present)}" |
| 54 | + |
| 55 | + |
| 56 | +def assert_transport_present(result: Dict[str, Any], host: HostSpec) -> None: |
| 57 | + """Verify at least one transport field is present in result. |
| 58 | +
|
| 59 | + Args: |
| 60 | + result: Serialized adapter output |
| 61 | + host: HostSpec with transport fields derived from fields.py |
| 62 | + """ |
| 63 | + transport_fields = host.get_transport_fields() |
| 64 | + present = set(result.keys()) & transport_fields |
| 65 | + assert present, ( |
| 66 | + f"[{host.host_name}] No transport field present in result. " |
| 67 | + f"Expected one of: {sorted(transport_fields)}" |
| 68 | + ) |
| 69 | + |
| 70 | + |
| 71 | +def assert_transport_mutual_exclusion(result: Dict[str, Any], host: HostSpec) -> None: |
| 72 | + """Verify exactly one transport field is present in result. |
| 73 | +
|
| 74 | + Args: |
| 75 | + result: Serialized adapter output |
| 76 | + host: HostSpec with transport fields derived from fields.py |
| 77 | + """ |
| 78 | + transport_fields = host.get_transport_fields() |
| 79 | + present = set(result.keys()) & transport_fields |
| 80 | + assert len(present) == 1, ( |
| 81 | + f"[{host.host_name}] Expected exactly 1 transport, " |
| 82 | + f"got {len(present)}: {sorted(present)}" |
| 83 | + ) |
| 84 | + |
| 85 | + |
| 86 | +def assert_field_mappings_applied(result: Dict[str, Any], host: HostSpec) -> None: |
| 87 | + """Verify field mappings from fields.py were applied. |
| 88 | +
|
| 89 | + For hosts with field mappings (e.g., Codex), universal field names |
| 90 | + should NOT appear in the result — only the mapped names should. |
| 91 | +
|
| 92 | + Args: |
| 93 | + result: Serialized adapter output |
| 94 | + host: HostSpec with field_mappings derived from fields.py |
| 95 | + """ |
| 96 | + for universal, host_specific in host.field_mappings.items(): |
| 97 | + if universal in result: |
| 98 | + assert False, ( |
| 99 | + f"[{host.host_name}] Universal field '{universal}' should have been " |
| 100 | + f"mapped to '{host_specific}'" |
| 101 | + ) |
| 102 | + |
| 103 | + |
| 104 | +def assert_tool_lists_coexist(result: Dict[str, Any], host: HostSpec) -> None: |
| 105 | + """Verify both allowlist and denylist fields are present in result. |
| 106 | +
|
| 107 | + Only meaningful for hosts that support tool lists. Skips silently |
| 108 | + if the host has no tool list configuration. |
| 109 | +
|
| 110 | + Args: |
| 111 | + result: Serialized adapter output |
| 112 | + host: HostSpec with tool list config derived from fields.py |
| 113 | + """ |
| 114 | + tool_config = host.get_tool_list_config() |
| 115 | + if not tool_config: |
| 116 | + return |
| 117 | + |
| 118 | + allowlist = tool_config["allowlist"] |
| 119 | + denylist = tool_config["denylist"] |
| 120 | + |
| 121 | + assert ( |
| 122 | + allowlist in result |
| 123 | + ), f"[{host.host_name}] Allowlist field '{allowlist}' missing from result" |
| 124 | + assert ( |
| 125 | + denylist in result |
| 126 | + ), f"[{host.host_name}] Denylist field '{denylist}' missing from result" |
| 127 | + |
| 128 | + |
| 129 | +def assert_unsupported_field_absent( |
| 130 | + result: Dict[str, Any], host: HostSpec, field_name: str |
| 131 | +) -> None: |
| 132 | + """Verify a specific unsupported field is not in result. |
| 133 | +
|
| 134 | + Args: |
| 135 | + result: Serialized adapter output |
| 136 | + host: HostSpec (used for error context) |
| 137 | + field_name: The unsupported field that should have been filtered |
| 138 | + """ |
| 139 | + assert field_name not in result, ( |
| 140 | + f"[{host.host_name}] Unsupported field '{field_name}' should have been " |
| 141 | + f"filtered but is present in result" |
| 142 | + ) |
0 commit comments