pryscraper/tests/test_x402_mcp_spec.py
cryptorugmunch 0ecc250349 refactor(pry): rename 19 root modules to pry_<name> to remove naming collision with routers/
The duplicate file names (advanced, agency, auth, compliance, costing, etc.) in
both root and routers/ made imports ambiguous. Renamed root modules to
pry_<name>.py so:

  from quality import X     ->  from pry_quality import X
  from routers.quality import router  (unchanged)

Also:
- Renamed x402.py to pry_x402/ package directory
- Fixed 21+ bare imports across api.py, deps.py, routers/, tests/, llm_providers/

Tests: 593 passed, 1 skipped (test_ready_returns_200 fails pre-existing
because Ollama is unreachable from test env, unrelated to this refactor).

Audit item 8.
2026-07-06 10:25:44 +02:00

348 lines
10 KiB
Python

# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
"""Tests for x402 and MCP spec compliance."""
import asyncio
import base64
import json
from typing import Any
from pry_mcp import (
MCP_PROTOCOL_VERSION,
PRY_PROMPTS,
PRY_RESOURCES,
PRY_TOOLS,
make_fallback_server,
register_all,
)
from pry_x402 import (
X402_PRICING,
FacilitatorRouter,
X402Asset,
X402Handler,
X402Network,
X402Scheme,
_decode_payment_signature,
_encode_payment_required_body,
build_payment_required,
set_facilitator_router,
)
class _AlwaysVerifyRouter(FacilitatorRouter):
"""Test router that always verifies payments without network calls."""
def __init__(self) -> None:
super().__init__(facilitators=[])
async def verify(
self,
payment_id: str,
tx_hash: str,
network: str,
asset: str,
amount_usd: float,
) -> dict[str, Any]:
return {"verified": True, "reason": "", "settlement": None, "facilitator": "test"}
async def settle(
self,
payment_id: str,
tx_hash: str,
network: str,
asset: str,
amount_usd: float,
) -> dict[str, Any]:
return {
"success": True,
"transaction": tx_hash,
"network": network,
"payment_id": payment_id,
"facilitator": "test",
"settled_at": "2026-01-01T00:00:00+00:00",
}
# ── MCP spec compliance ──
def test_mcp_protocol_version() -> None:
"""MCP 2024-11-05 is the current spec version."""
assert MCP_PROTOCOL_VERSION == "2024-11-05"
def test_tools_have_input_schema() -> None:
"""Every tool must have a JSON schema for inputs."""
for tool in PRY_TOOLS:
assert "name" in tool
assert "description" in tool
assert "inputSchema" in tool
schema = tool["inputSchema"]
assert schema["type"] == "object"
assert "properties" in schema
assert "required" in schema
def test_tools_have_output_schema() -> None:
"""Best practice: tools should declare output schemas."""
tools_with_output = [t for t in PRY_TOOLS if "outputSchema" in t]
assert len(tools_with_output) >= 3
def test_resources_have_required_fields() -> None:
for r in PRY_RESOURCES:
assert "uri" in r
assert "name" in r
assert "description" in r
assert "mimeType" in r
def test_prompts_have_required_fields() -> None:
for p in PRY_PROMPTS:
assert "name" in p
assert "description" in p
assert "arguments" in p
for arg in p["arguments"]:
assert "name" in arg
assert "description" in arg
assert "required" in arg
def test_initialize_response() -> None:
"""Test that initialize returns proper protocol version and capabilities."""
server = make_fallback_server()
response = asyncio.run(
server.handle_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"clientInfo": {"name": "test", "version": "1.0"}},
}
)
)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 1
assert response["result"]["protocolVersion"] == MCP_PROTOCOL_VERSION
assert "tools" in response["result"]["capabilities"]
assert "resources" in response["result"]["capabilities"]
assert "prompts" in response["result"]["capabilities"]
def test_tools_list() -> None:
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
)
assert "tools" in response["result"]
assert len(response["result"]["tools"]) >= 10
def test_tool_call_returns_content_array() -> None:
"""tools/call must return content array per MCP spec."""
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "pry_x402_pricing", "arguments": {}},
}
)
)
assert "content" in response["result"]
assert isinstance(response["result"]["content"], list)
assert "isError" in response["result"]
def test_resources_list() -> None:
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "resources/list"})
)
assert "resources" in response["result"]
assert len(response["result"]["resources"]) >= 3
def test_resource_read_returns_contents() -> None:
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "resources/read",
"params": {"uri": "pry://stats"},
}
)
)
assert "contents" in response["result"]
def test_prompts_list() -> None:
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "prompts/list"})
)
assert "prompts" in response["result"]
assert len(response["result"]["prompts"]) >= 5
def test_prompt_get_returns_messages() -> None:
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "prompts/get",
"params": {"name": "research_company", "arguments": {"company_name": "Anthropic"}},
}
)
)
assert "messages" in response["result"]
assert len(response["result"]["messages"]) > 0
def test_ping() -> None:
server = make_fallback_server()
response = asyncio.run(server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "ping"}))
assert response["result"] == {}
def test_unknown_method_error() -> None:
server = make_fallback_server()
response = asyncio.run(
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "unknown_method"})
)
assert "error" in response
assert response["error"]["code"] == -32601
# ── x402 spec compliance ──
def test_x402_atomic_units() -> None:
"""Prices must be in atomic units (6 decimals for USDC)."""
for op, info in X402_PRICING.items():
amount_atomic = int(info["price_usd"] * 1_000_000)
assert amount_atomic > 0, f"Operation {op} has zero price"
def test_x402_accepts_array_format() -> None:
"""PaymentRequired must have accepts array per x402 spec."""
body = build_payment_required("scrape", "https://pry.dev/api/scrape")
assert body["x402Version"] == 1
assert "accepts" in body
assert isinstance(body["accepts"], list)
assert len(body["accepts"]) >= 1
def test_x402_acceptance_has_required_fields() -> None:
"""Each acceptance option must have required fields per x402 spec."""
body = build_payment_required("scrape")
for acceptance in body["accepts"]:
assert "scheme" in acceptance
assert "network" in acceptance
assert "maxAmountRequired" in acceptance
assert "resource" in acceptance
assert "payToAddress" in acceptance
assert "asset" in acceptance
def test_x402_base64_encoding() -> None:
"""PAYMENT-REQUIRED header must be Base64-encoded JSON."""
body = build_payment_required("scrape")
encoded = _encode_payment_required_body(body)
decoded = base64.b64decode(encoded).decode()
parsed = json.loads(decoded)
assert parsed == body
def test_x402_signature_decoding() -> None:
"""PAYMENT-SIGNATURE header must be Base64-encoded JSON."""
test_payload = {"scheme": "exact", "network": "base", "payload": "0x1234"}
encoded = base64.b64encode(json.dumps(test_payload).encode()).decode()
decoded = _decode_payment_signature(encoded)
assert decoded == test_payload
def test_x402_handler_create_payment_required() -> None:
handler = X402Handler()
body, headers = handler.create_payment_required("scrape", "https://pry.dev/api/scrape")
assert body["x402Version"] == 1
assert "PAYMENT-REQUIRED" in headers
decoded = json.loads(base64.b64decode(headers["PAYMENT-REQUIRED"]).decode())
assert decoded == body
def test_x402_handler_require_payment() -> None:
handler = X402Handler()
response = handler.require_payment("crawl")
assert response["status_code"] == 402
assert "PAYMENT-REQUIRED" in response["headers"]
assert "accepts" in response["body"]
def test_x402_handler_verify_and_settle() -> None:
set_facilitator_router(_AlwaysVerifyRouter())
try:
handler = X402Handler()
payment_id = "test-pay-123"
tx_hash = "0xabc123"
verify_result = asyncio.run(handler.verify_payment(payment_id, tx_hash))
assert verify_result["verified"] is True
assert handler.is_paid(payment_id) is True
settlement = asyncio.run(handler.settle_payment(payment_id, tx_hash))
assert settlement["success"] is True
assert settlement["transaction"] == tx_hash
finally:
set_facilitator_router(FacilitatorRouter())
def test_x402_multi_chain_support() -> None:
"""x402 should support multiple chains."""
body = build_payment_required("scrape")
networks = {a["network"] for a in body["accepts"]}
assert "base" in networks
assert "solana" in networks
def test_x402_scheme_enum() -> None:
assert X402Scheme.EXACT.value == "exact"
assert X402Scheme.UPTO.value == "upto"
def test_x402_network_enum() -> None:
assert X402Network.BASE.value == "base"
assert X402Network.SOLANA.value == "solana"
assert X402Network.ETHEREUM.value == "ethereum"
def test_x402_asset_enum() -> None:
assert X402Asset.USDC.value == "USDC"
assert X402Asset.USDT.value == "USDT"
assert X402Asset.ETH.value == "ETH"
def test_x402_stats_includes_standards() -> None:
handler = X402Handler()
stats = handler.get_stats()
assert "wallet" in stats
assert "facilitator" in stats
assert "pricing" in stats
assert "supported_networks" in stats
assert "supported_assets" in stats
assert "supported_schemes" in stats
assert "base" in stats["supported_networks"]
assert "solana" in stats["supported_networks"]