mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M4 implementada e backlog validado
This commit is contained in:
0
RI/tests/contract/__init__.py
Normal file
0
RI/tests/contract/__init__.py
Normal file
81
RI/tests/contract/conftest.py
Normal file
81
RI/tests/contract/conftest.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Contract tests connect directly to a domain server -- never through the
|
||||
Gateway -- because a tool's input/output contract is a property of the
|
||||
domain server itself (ADR-023: it's "dumb" about authorization, but not
|
||||
about its own data shape), independent of who is or isn't entitled to call
|
||||
it. Reuses `RunningServer`/`free_port` from the e2e suite rather than
|
||||
duplicating them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
import uvicorn
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
|
||||
create_mcp_http_client,
|
||||
streamable_http_client,
|
||||
)
|
||||
|
||||
from tests.e2e.conftest import RunningServer, free_port
|
||||
|
||||
|
||||
async def call_tool_directly(
|
||||
server_url: str, tool_name: str, arguments: dict[str, Any]
|
||||
) -> types.CallToolResult:
|
||||
"""No auth header at all -- these servers are `main()`-unwrapped here
|
||||
(no `BackendCredentialGate`), matching how the rest of the e2e suite
|
||||
already constructs them for in-process tests."""
|
||||
async with (
|
||||
create_mcp_http_client() as http_client,
|
||||
streamable_http_client(server_url, http_client=http_client) as (read, write),
|
||||
ClientSession(read, write) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
result = await session.call_tool(tool_name, arguments)
|
||||
if not isinstance(result, types.CallToolResult):
|
||||
raise TypeError(f"Unexpected result type: {type(result)!r}")
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sales_domain_url() -> AsyncIterator[str]:
|
||||
import example_mcp_servers.sales_domain.server as sales_module
|
||||
|
||||
port = free_port()
|
||||
server = RunningServer(
|
||||
uvicorn.Config(
|
||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{port}/mcp"
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def finance_domain_url() -> AsyncIterator[str]:
|
||||
import example_mcp_servers.finance_domain.server as finance_module
|
||||
|
||||
port = free_port()
|
||||
server = RunningServer(
|
||||
uvicorn.Config(
|
||||
finance_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{port}/mcp"
|
||||
finally:
|
||||
await server.stop()
|
||||
106
RI/tests/contract/test_finance_domain.py
Normal file
106
RI/tests/contract/test_finance_domain.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Contract tests for the Finance domain server (EP-15-T01, README.md §7.2).
|
||||
|
||||
See test_sales_domain.py's module docstring for why the output schemas
|
||||
below are hand-written rather than reused from the SDK's auto-generated
|
||||
(too-generic) ones.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
from tests.contract.conftest import call_tool_directly
|
||||
|
||||
_INVOICE_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["invoice_id", "customer_id", "amount", "status"],
|
||||
"properties": {
|
||||
"invoice_id": {"type": "string"},
|
||||
"customer_id": {"type": "string"},
|
||||
"amount": {"type": "number"},
|
||||
"status": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_PAYMENT_CREATE_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["payment_id", "invoice_id", "amount", "status"],
|
||||
"properties": {
|
||||
"payment_id": {"type": "string"},
|
||||
"invoice_id": {"type": "string"},
|
||||
"amount": {"type": "number"},
|
||||
"status": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_PAYMENT_EXECUTE_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["payment_id", "invoice_id", "amount", "status", "executed_amount"],
|
||||
"properties": {
|
||||
"payment_id": {"type": "string"},
|
||||
"invoice_id": {"type": "string"},
|
||||
"amount": {"type": "number"},
|
||||
"status": {"type": "string"},
|
||||
"executed_amount": {"type": "number"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
async def test_invoice_get_output_matches_its_contract(finance_domain_url: str) -> None:
|
||||
result = await call_tool_directly(
|
||||
finance_domain_url, "finance.invoice.get", {"invoice_id": "inv-001"}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _INVOICE_SCHEMA)
|
||||
|
||||
|
||||
async def test_payment_create_output_matches_its_contract(finance_domain_url: str) -> None:
|
||||
result = await call_tool_directly(
|
||||
finance_domain_url, "finance.payment.create", {"invoice_id": "inv-001", "amount": 500.0}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _PAYMENT_CREATE_SCHEMA)
|
||||
|
||||
|
||||
async def test_payment_execute_output_matches_its_contract(finance_domain_url: str) -> None:
|
||||
created = await call_tool_directly(
|
||||
finance_domain_url, "finance.payment.create", {"invoice_id": "inv-001", "amount": 500.0}
|
||||
)
|
||||
payment_id = created.structured_content["payment_id"]
|
||||
|
||||
result = await call_tool_directly(
|
||||
finance_domain_url,
|
||||
"finance.payment.execute",
|
||||
{"payment_id": payment_id, "amount": 500.0},
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _PAYMENT_EXECUTE_SCHEMA)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name",
|
||||
["finance.invoice.get", "finance.payment.create", "finance.payment.execute"],
|
||||
)
|
||||
async def test_a_call_missing_its_required_field_fails_the_contract(
|
||||
finance_domain_url: str, tool_name: str
|
||||
) -> None:
|
||||
result = await call_tool_directly(finance_domain_url, tool_name, {})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_a_wrong_typed_field_fails_the_contract(finance_domain_url: str) -> None:
|
||||
result = await call_tool_directly(
|
||||
finance_domain_url,
|
||||
"finance.payment.create",
|
||||
{"invoice_id": "inv-001", "amount": "not-a-number"},
|
||||
)
|
||||
|
||||
assert result.is_error is True
|
||||
156
RI/tests/contract/test_sales_domain.py
Normal file
156
RI/tests/contract/test_sales_domain.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Contract tests for the Sales domain server (EP-15-T01, README.md §7.1).
|
||||
|
||||
The MCP SDK auto-generates `input_schema` from each tool's Python signature
|
||||
(real, per-field types/required -- verified empirically: a missing required
|
||||
field or a wrong-typed one is rejected server-side via Pydantic, `is_error`
|
||||
True, before this RI's own code ever runs) and *validates every call
|
||||
against it* -- so `input_schema` conformance is exercised directly below,
|
||||
not re-implemented. `output_schema`, however, is auto-generated from the
|
||||
Python return type (`dict[str, object]` throughout this RI's example
|
||||
servers) and comes out too generic (`additionalProperties: true`) to be a
|
||||
meaningful contract on its own -- `CapabilityManifest` (EP-02-T01) does not
|
||||
duplicate a schema either (the manifest's job is entitlement/risk metadata,
|
||||
not shape). This file's own hand-written `jsonschema` per tool *is* the
|
||||
practical output contract EP-15-T01 asks for, validated against the real,
|
||||
running server -- not a schema nobody actually checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
from tests.contract.conftest import call_tool_directly
|
||||
|
||||
_CUSTOMER_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["customer_id", "name", "region"],
|
||||
"properties": {
|
||||
"customer_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"region": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_CUSTOMER_SEARCH_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["results"],
|
||||
"properties": {"results": {"type": "array", "items": _CUSTOMER_SCHEMA}},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_ORDER_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["order_id", "customer_id", "status"],
|
||||
"properties": {
|
||||
"order_id": {"type": "string"},
|
||||
"customer_id": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_INVENTORY_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["sku", "available"],
|
||||
"properties": {"sku": {"type": "string"}, "available": {"type": "integer"}},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_QUOTE_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["quote_id", "customer_id", "amount", "region", "status"],
|
||||
"properties": {
|
||||
"quote_id": {"type": "string"},
|
||||
"customer_id": {"type": "string"},
|
||||
"amount": {"type": "number"},
|
||||
"region": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
async def test_customer_search_output_matches_its_contract(sales_domain_url: str) -> None:
|
||||
result = await call_tool_directly(
|
||||
sales_domain_url, "sales.customer.search", {"query": "Aurora"}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _CUSTOMER_SEARCH_SCHEMA)
|
||||
|
||||
|
||||
async def test_customer_get_output_matches_its_contract(sales_domain_url: str) -> None:
|
||||
result = await call_tool_directly(
|
||||
sales_domain_url, "sales.customer.get", {"customer_id": "cust-001"}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _CUSTOMER_SCHEMA)
|
||||
|
||||
|
||||
async def test_order_get_output_matches_its_contract(sales_domain_url: str) -> None:
|
||||
result = await call_tool_directly(sales_domain_url, "sales.order.get", {"order_id": "ord-001"})
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _ORDER_SCHEMA)
|
||||
|
||||
|
||||
async def test_inventory_check_output_matches_its_contract(sales_domain_url: str) -> None:
|
||||
result = await call_tool_directly(sales_domain_url, "sales.inventory.check", {"sku": "sku-100"})
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _INVENTORY_SCHEMA)
|
||||
|
||||
|
||||
async def test_quote_create_output_matches_its_contract(sales_domain_url: str) -> None:
|
||||
result = await call_tool_directly(
|
||||
sales_domain_url, "sales.quote.create", {"customer_id": "cust-001", "amount": 100.0}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _QUOTE_SCHEMA)
|
||||
|
||||
|
||||
async def test_quote_update_output_matches_its_contract(sales_domain_url: str) -> None:
|
||||
created = await call_tool_directly(
|
||||
sales_domain_url, "sales.quote.create", {"customer_id": "cust-001", "amount": 100.0}
|
||||
)
|
||||
quote_id = created.structured_content["quote_id"]
|
||||
|
||||
result = await call_tool_directly(
|
||||
sales_domain_url, "sales.quote.update", {"quote_id": quote_id, "amount": 200.0}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
jsonschema.validate(result.structured_content, _QUOTE_SCHEMA)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name",
|
||||
[
|
||||
"sales.customer.search",
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.inventory.check",
|
||||
"sales.quote.create",
|
||||
"sales.quote.update",
|
||||
],
|
||||
)
|
||||
async def test_a_call_missing_its_required_field_fails_the_contract(
|
||||
sales_domain_url: str, tool_name: str
|
||||
) -> None:
|
||||
"""EP-15-T01's acceptance criterion, the input side: every tool's
|
||||
real, auto-generated `input_schema` is actually enforced server-side --
|
||||
an empty argument set (violating every tool's own required fields)
|
||||
must always be rejected, never silently coerced or ignored."""
|
||||
result = await call_tool_directly(sales_domain_url, tool_name, {})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_a_wrong_typed_field_fails_the_contract(sales_domain_url: str) -> None:
|
||||
result = await call_tool_directly(sales_domain_url, "sales.inventory.check", {"sku": 12345})
|
||||
|
||||
assert result.is_error is True
|
||||
Reference in New Issue
Block a user