Skip to content

Commit cd2d707

Browse files
vvillait88claude
andcommitted
test+docs: redistribute coverage tests + add per-product policy example
- Move test_coverage_bumps.py contents into the topical homes: - probe / sample_x402_accept tests tests/test_discovery.py - create_mppx_stripe wrapper tests tests/test_stripe_multichain.py - _apply_dynamic_options edges tests/test_sessions.py - Delete tests/test_coverage_bumps.py — tests should live with the surface they exercise, not in a coverage-fillers bucket - examples/per_product_policy_merchant.py: copy-paste FastAPI merchant with three products (hard wine, anonymous merch, soft fraud-signal print) exercising PolicyBlock + build_gate_from_policy + run_gate_with_enforcement + shipping_country_allowed + shipping_state_allowed - README + CLAUDE.md row for the new example 535 tests, 95.02% coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7199fda commit cd2d707

8 files changed

Lines changed: 415 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime —
4444
| `stripe_multichain_merchant.py` | Stripe-anchored multichain (PaymentIntent → tempo/base/solana deposit addresses) |
4545
| `variable_cost_merchant.py` | Pay-per-actual-usage on **two protocols**: x402 upto (Permit2 + Settlement-Overrides) AND MPP tempo session (channel + SSE + mid-stream vouchers) |
4646
| `compliance_merchant.py` | Regulated-goods merchant — full compliance gate + custom `on_denied` composing the denial helpers (`verification_agent_instructions`, `is_fixable_denial`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `denial_reason_to_body`/`denial_reason_status`) |
47+
| `per_product_policy_merchant.py` | Multi-product merchant where each row carries its own compliance policy. One product hard-gates KYC + age + state; another is anonymous; a third uses `enforcement="soft"` (request KYC but don't block sale). Demonstrates `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`. |
4748

4849
## Identity model
4950

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ pip install agentscore-commerce[fastapi] # or [flask], [django], [aiohttp], [s
1616
| Submodule | What it provides |
1717
|---|---|
1818
| `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware: KYC, sanctions, age, jurisdiction. `AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic), `get_assess_data(...)`, `capture_wallet(...)`, `verify_wallet_signer_match(...)`. |
19-
| `agentscore_commerce.identity` (package level) | Re-exports the denial helpers: `denial_reason_status`, `denial_reason_to_body`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `verification_agent_instructions`, `is_fixable_denial`, `FIXABLE_DENIAL_REASONS`. |
19+
| `agentscore_commerce.identity` (package level) | Re-exports the denial helpers: `denial_reason_status`, `denial_reason_to_body`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `verification_agent_instructions`, `is_fixable_denial`, `FIXABLE_DENIAL_REASONS`. Also re-exports the per-product policy helpers: `PolicyBlock`, `GateResult`, `EnforcementMode`, `IdentityStatus`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed` — for multi-product merchants where each product carries its own compliance config (hard gate vs soft vs none, per-product shipping allowlists). |
2020
| `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (v1↔v2 amount field shim — emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlement_override_header`, `dispatch_settlement_by_network`, `extract_payment_signer` (returns `PaymentSigner({address, network})`), `register_x402_schemes_v1_v2`; drop-in x402 helpers: `validate_x402_network_config` (boot-time guard), `verify_x402_request` (parse + validate inbound X-Payment), `process_x402_settle` (verify-then-settle with one call). |
2121
| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers — `awal x402 details` etc.), `sample_x402_accept_for_network` (USDC sample-accept builder for known CAIP-2 networks), `build_well_known_mpp`, `build_llms_txt` + `llms_txt_identity_section` + `llms_txt_payment_section` (compact + verbose modes), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware that emits `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces — defaults cover `/openapi.json`, `/llms.txt`, `/.well-known/{mpp.json,agent-card.json,ucp}`, `/favicon.{png,ico}`; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). |
2222
| `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata`, `build_how_to_pay`, `build_agent_instructions`; `respond_402` — drop-in 402 emit that preserves pympp's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `build_validation_error` — structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. |

agentscore_commerce/identity/policy.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,10 @@
3232
from dataclasses import dataclass
3333
from typing import TYPE_CHECKING, Any, Literal, TypedDict
3434

35-
from agentscore_commerce.identity.fastapi import AgentScoreGate
36-
3735
if TYPE_CHECKING:
3836
from collections.abc import Mapping
3937

38+
from agentscore_commerce.identity.fastapi import AgentScoreGate
4039
from agentscore_commerce.identity.sessions import CreateSessionOnMissing
4140

4241
EnforcementMode = Literal["hard", "soft"]
@@ -100,6 +99,10 @@ def build_gate_from_policy(
10099
return None
101100
if not policy.get("enforcement"):
102101
return None
102+
# Lazy import — avoids circular import at package init time
103+
# (identity package init pulls policy → fastapi → payment.signer → identity).
104+
from agentscore_commerce.identity.fastapi import AgentScoreGate
105+
103106
return AgentScoreGate(
104107
api_key=api_key,
105108
base_url=base_url,

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Runnable, copy-pasteable example integrations covering the most common merchant
1010
| [`stripe_multichain_merchant.py`](./stripe_multichain_merchant.py) | Stripe-anchored multi-chain | Stripe PaymentIntent with deposit_options for tempo/base/solana; crypto deposits flow through Stripe. Includes testnet `simulate_crypto_deposit` helper. |
1111
| [`variable_cost_merchant.py`](./variable_cost_merchant.py) | Pay-per-actual-usage (LLM, transcode, etc.) | Same use case on **two protocols**: x402 upto (Permit2 authorize-max → `Settlement-Overrides` settle-actual) AND MPP tempo session (channel + SSE + mid-stream vouchers). Vendor offers both. |
1212
| [`compliance_merchant.py`](./compliance_merchant.py) | Regulated-goods merchant (wine, cannabis, etc.) | Full compliance gate + custom `on_denied` composing commerce helpers: `verification_agent_instructions`, `is_fixable_denial`, `build_contact_support_next_steps`, `denial_reason_to_body`/`denial_reason_status`, `build_signer_mismatch_body`. Shows how vendors write only the business-specific branches and let commerce handle the rest. |
13+
| [`per_product_policy_merchant.py`](./per_product_policy_merchant.py) | Multi-product merchant with mixed compliance needs | One product carries a hard gate (wine — KYC + 21 + US-state allowlist), another has no gate at all (anonymous merch, ships anywhere), a third uses `enforcement="soft"` (request KYC as a fraud signal but accept anonymous sales, stamping `identity_status="unverified"` on the order). Uses `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`. |
1314

1415
## How to use
1516

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Example: multi-product merchant with per-product compliance policy + soft mode
2+
3+
Scenario: you sell several products with different compliance needs.
4+
- Wine: hard gate, KYC + 21+ + US-only + state allowlist (regulated alcohol)
5+
- Tee: no gate at all — fully anonymous, ship anywhere
6+
- Limited print: SOFT gate — request KYC for fraud signals, but don't block sale
7+
if the buyer skips it; record identity_status="unverified" instead
8+
9+
Each product carries its own policy block (in this example, a Python dict the
10+
merchant looks up from a database row). The route uses three helpers from
11+
``agentscore_commerce.identity.policy``:
12+
13+
- build_gate_from_policy(policy, *, api_key) → AgentScoreGate | None
14+
Returns None when the policy has no enforcement (no gate fires).
15+
- run_gate_with_enforcement(request, gate, *, enforcement) → GateResult
16+
Runs the gate, swallows soft denials, returns a structured result.
17+
- shipping_country_allowed / shipping_state_allowed
18+
Per-product shipping allowlists (NULL = ship anywhere).
19+
20+
The pattern was extracted from agentscore/store. See its
21+
``store/routes/purchase.py`` for the full per-request flow including code
22+
redemption + order persistence.
23+
24+
Peer deps:
25+
pip install agentscore-commerce[fastapi]
26+
27+
Env vars:
28+
AGENTSCORE_API_KEY — your AgentScore API key
29+
30+
Run: uvicorn examples.per_product_policy_merchant:app --port 3000
31+
"""
32+
33+
import os
34+
from typing import Any
35+
36+
from fastapi import FastAPI, Request
37+
from fastapi.responses import JSONResponse
38+
39+
from agentscore_commerce.identity.policy import (
40+
PolicyBlock,
41+
build_gate_from_policy,
42+
run_gate_with_enforcement,
43+
shipping_country_allowed,
44+
shipping_state_allowed,
45+
)
46+
47+
API_KEY = os.environ.get("AGENTSCORE_API_KEY", "ask_test_dummy")
48+
49+
# A merchant would normally read these from a `products` table. Each row carries
50+
# its own compliance config; the keys match `PolicyBlock`.
51+
PRODUCTS: dict[str, dict[str, Any]] = {
52+
"wine-cabernet": {
53+
"name": "Reserve Cabernet",
54+
"price_usd": 75.00,
55+
"policy": PolicyBlock(
56+
enforcement="hard",
57+
require_kyc=True,
58+
require_sanctions_clear=True,
59+
min_age=21,
60+
allowed_jurisdictions=["US"],
61+
allowed_shipping_countries=["US"],
62+
allowed_shipping_states=["CA", "NY", "TX", "FL", "WA"], # abridged
63+
),
64+
},
65+
"tee": {
66+
"name": "Cotton Tee",
67+
"price_usd": 30.00,
68+
"policy": None, # No gate; ship anywhere; identity_status="anonymous"
69+
},
70+
"limited-print": {
71+
"name": "Limited Edition Print (200/500)",
72+
"price_usd": 200.00,
73+
# Soft gate: request KYC as a fraud signal, but accept anonymous sales.
74+
# On miss, identity_status="unverified" stamps the order so ops can flag it.
75+
"policy": PolicyBlock(enforcement="soft", require_kyc=True),
76+
},
77+
}
78+
79+
80+
app = FastAPI()
81+
82+
83+
@app.post("/purchase")
84+
async def purchase(request: Request) -> JSONResponse:
85+
body = await request.json()
86+
slug = body.get("product_slug")
87+
shipping = body.get("shipping", {})
88+
89+
product = PRODUCTS.get(slug)
90+
if product is None:
91+
return JSONResponse({"error": {"code": "product_not_found"}}, status_code=400)
92+
93+
policy = product["policy"]
94+
95+
# Per-product shipping allowlists. NULL policy → ship anywhere.
96+
if not shipping_country_allowed(shipping.get("country", ""), policy):
97+
return JSONResponse(
98+
{"error": {"code": "unsupported_jurisdiction", "message": f"Cannot ship to {shipping.get('country')}."}},
99+
status_code=400,
100+
)
101+
if not shipping_state_allowed(shipping.get("state", ""), shipping.get("country", ""), policy):
102+
return JSONResponse(
103+
{"error": {"code": "unsupported_jurisdiction", "message": f"Cannot ship to {shipping.get('state')}."}},
104+
status_code=400,
105+
)
106+
107+
# Per-product identity gate.
108+
enforcement = policy["enforcement"] if policy and "enforcement" in policy else None
109+
gate = build_gate_from_policy(policy, api_key=API_KEY)
110+
gate_result = await run_gate_with_enforcement(request, gate, enforcement=enforcement)
111+
112+
if gate_result.status == "denied":
113+
# Hard mode: propagate the gate's structured 403 verbatim.
114+
return JSONResponse(content=gate_result.denial_body, status_code=gate_result.denial_status or 403)
115+
116+
# gate_result.status is one of: "verified" (gate ran + passed),
117+
# "unverified" (soft mode swallowed a denial), "anonymous" (no gate fired).
118+
# Persist this on the order row so ops can distinguish soft passes from hard
119+
# passes and from no-gate-product orders. For the limited print, an
120+
# "unverified" status is a real fraud signal worth flagging in ops.
121+
identity_status = gate_result.status
122+
123+
# ... settle payment, create order with `identity_status` column, return 200 ...
124+
return JSONResponse(
125+
{
126+
"order": {"product": product["name"], "total_usd": product["price_usd"]},
127+
"identity_status": identity_status,
128+
}
129+
)

tests/test_discovery.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
22

3+
import pytest
4+
35
from agentscore_commerce.discovery import (
46
BuildAgentScoreOpenApiSnippetsInput,
57
DiscoveryProbeOptions,
@@ -152,3 +154,172 @@ def test_solana_only_no_base(self):
152154
assert "### How to pay with x402 (Solana)" in section
153155
assert "--chain solana" in section
154156
assert "--chain base" not in section
157+
158+
159+
# ── sample_x402_accept_for_network: every registry branch ───────────────────
160+
161+
162+
def test_sample_accept_base_mainnet() -> None:
163+
from agentscore_commerce.discovery.probe import sample_x402_accept_for_network
164+
165+
e = sample_x402_accept_for_network("eip155:8453")
166+
assert e is not None
167+
assert e["network"] == "eip155:8453"
168+
assert e["scheme"] == "exact"
169+
assert e["extra"] == {"name": "USDC", "version": "2"}
170+
171+
172+
def test_sample_accept_base_sepolia() -> None:
173+
from agentscore_commerce.discovery.probe import sample_x402_accept_for_network
174+
175+
e = sample_x402_accept_for_network("eip155:84532")
176+
assert e is not None
177+
assert e["network"] == "eip155:84532"
178+
179+
180+
def test_sample_accept_solana_mainnet() -> None:
181+
from agentscore_commerce.discovery.probe import sample_x402_accept_for_network
182+
183+
e = sample_x402_accept_for_network("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")
184+
assert e is not None
185+
assert "extra" not in e
186+
assert e["payTo"] == "11111111111111111111111111111111"
187+
188+
189+
def test_sample_accept_solana_devnet() -> None:
190+
from agentscore_commerce.discovery.probe import sample_x402_accept_for_network
191+
192+
e = sample_x402_accept_for_network("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1")
193+
assert e is not None
194+
assert e["network"].startswith("solana:")
195+
196+
197+
def test_sample_accept_unknown_network_returns_none() -> None:
198+
from agentscore_commerce.discovery.probe import sample_x402_accept_for_network
199+
200+
assert sample_x402_accept_for_network("eip155:1") is None
201+
202+
203+
# ── build_discovery_probe_response: x402 sample paths ───────────────────────
204+
205+
206+
def _probe_opts(**overrides: object):
207+
from agentscore_commerce.discovery.probe import DiscoveryProbeOptions
208+
209+
base: dict[str, object] = {
210+
"realm": "https://example.com",
211+
"sample_rail": "tempo-mainnet",
212+
"sample_amount_usd": 1.00,
213+
"sample_recipient": "0x0000000000000000000000000000000000000001",
214+
}
215+
base.update(overrides)
216+
return DiscoveryProbeOptions(**base) # type: ignore[arg-type]
217+
218+
219+
def test_probe_response_without_x402_sample() -> None:
220+
from agentscore_commerce.discovery.probe import build_discovery_probe_response
221+
222+
resp = build_discovery_probe_response(_probe_opts())
223+
assert resp.status == 402
224+
assert "www-authenticate" in resp.headers
225+
assert "payment-required" not in resp.headers
226+
227+
228+
def test_probe_response_with_x402_sample_via_networks_shorthand() -> None:
229+
import json as _json
230+
231+
from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response
232+
233+
resp = build_discovery_probe_response(
234+
_probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"]))
235+
)
236+
assert resp.status == 402
237+
assert "payment-required" in resp.headers
238+
body = _json.loads(resp.body)
239+
assert body["x402Version"] == 2
240+
assert len(body["accepts"]) == 2
241+
242+
243+
def test_probe_response_with_explicit_accepts_overrides_networks_shorthand() -> None:
244+
import json as _json
245+
246+
from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response
247+
248+
custom = [{"scheme": "exact", "network": "fake", "asset": "X", "payTo": "Y"}]
249+
resp = build_discovery_probe_response(_probe_opts(x402_sample=X402SampleProbe(accepts=custom, version=1)))
250+
body = _json.loads(resp.body)
251+
assert body["x402Version"] == 1
252+
assert body["accepts"][0]["network"] == "fake"
253+
254+
255+
def test_probe_response_with_resource_url() -> None:
256+
import base64
257+
import json as _json
258+
259+
from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response
260+
261+
resp = build_discovery_probe_response(
262+
_probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532"], resource_url="https://example.com/api"))
263+
)
264+
decoded = _json.loads(base64.b64decode(resp.headers["payment-required"]).decode())
265+
assert decoded["resource"]["url"] == "https://example.com/api"
266+
267+
268+
def test_probe_response_with_docs_url() -> None:
269+
import json as _json
270+
271+
from agentscore_commerce.discovery.probe import build_discovery_probe_response
272+
273+
resp = build_discovery_probe_response(_probe_opts(docs_url="https://docs.example.com"))
274+
body = _json.loads(resp.body)
275+
assert body["docs"] == "https://docs.example.com"
276+
277+
278+
def test_probe_response_unknown_network_filtered_out() -> None:
279+
import json as _json
280+
281+
from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response
282+
283+
resp = build_discovery_probe_response(
284+
_probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532", "eip155:99999"]))
285+
)
286+
body = _json.loads(resp.body)
287+
assert len(body["accepts"]) == 1
288+
289+
290+
# ── is_discovery_probe_request ──────────────────────────────────────────────
291+
292+
293+
@pytest.mark.asyncio
294+
async def test_is_probe_empty_post() -> None:
295+
from agentscore_commerce.discovery.probe import is_discovery_probe_request
296+
297+
assert await is_discovery_probe_request("POST", None, "") is True
298+
299+
300+
@pytest.mark.asyncio
301+
async def test_is_probe_empty_object_post() -> None:
302+
from agentscore_commerce.discovery.probe import is_discovery_probe_request
303+
304+
assert await is_discovery_probe_request("POST", None, "{}") is True
305+
306+
307+
@pytest.mark.asyncio
308+
async def test_is_probe_non_post_rejected() -> None:
309+
from agentscore_commerce.discovery.probe import is_discovery_probe_request
310+
311+
assert await is_discovery_probe_request("GET", None, "") is False
312+
313+
314+
@pytest.mark.asyncio
315+
async def test_is_probe_with_payment_authz_rejected() -> None:
316+
from agentscore_commerce.discovery.probe import is_discovery_probe_request
317+
318+
assert await is_discovery_probe_request("POST", "Payment foo", "") is False
319+
320+
321+
@pytest.mark.asyncio
322+
async def test_is_probe_with_real_body_rejected() -> None:
323+
from agentscore_commerce.discovery.probe import is_discovery_probe_request
324+
325+
assert await is_discovery_probe_request("POST", None, '{"product": "x"}') is False

tests/test_sessions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,21 @@ async def test_non_dict_return_ignored(self):
349349
reason = await try_create_session_denial_reason(cfg, user_agent="ua", ctx={"x": 1})
350350
assert reason is not None
351351
assert reason.extra is None
352+
353+
354+
# ── _apply_dynamic_options edge cases ───────────────────────────────────────
355+
356+
357+
def test_apply_dynamic_options_passes_through_non_dict() -> None:
358+
from agentscore_commerce.identity.sessions import _apply_dynamic_options
359+
360+
body: dict[str, str] = {}
361+
assert _apply_dynamic_options(body, "not-a-dict") is body
362+
363+
364+
def test_apply_dynamic_options_picks_js_style_product_name() -> None:
365+
from agentscore_commerce.identity.sessions import _apply_dynamic_options
366+
367+
body: dict[str, str] = {}
368+
out = _apply_dynamic_options(body, {"productName": "Wine Store"})
369+
assert out["product_name"] == "Wine Store"

0 commit comments

Comments
 (0)