mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-13 05:01:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0237c6f4ce | ||
|
|
aa8e7079f3 | ||
|
|
4e6a092548 | ||
|
|
363eaeafb1 | ||
|
|
a9f01306da | ||
|
|
68f161f136 | ||
|
|
6ac9056331 | ||
|
|
361b1e70b9 |
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# PraisonAI-Tools environment variables (examples)
|
||||
#
|
||||
# Copy to `.env` and fill in the values for the tools you use.
|
||||
# Never commit real secrets.
|
||||
|
||||
# ── Stripe (StripeTool) ─────────────────────────────────────────────
|
||||
# Stripe secret key. Prefer least-privilege *restricted* keys (rk_...).
|
||||
# Use test keys (sk_test_/rk_test_) for development and live keys
|
||||
# (sk_live_/rk_live_) only in production. STRIPE_API_KEY is accepted as a
|
||||
# fallback if STRIPE_SECRET_KEY is unset.
|
||||
STRIPE_SECRET_KEY=sk_test_your_key_here
|
||||
# STRIPE_API_KEY=sk_test_your_key_here
|
||||
@@ -3,7 +3,7 @@
|
||||
"stack": "claude",
|
||||
"profile": "tools",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.10",
|
||||
"hubRef": "v1.1.11",
|
||||
"templateVersion": "1.1.6",
|
||||
"installedAt": "2026-09-07T12:54:25Z"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]1
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.11
|
||||
@@ -16,8 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]1
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.11
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -25,8 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]1
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.11
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]0
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]1
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.11
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -87,9 +87,9 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]1
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.11
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]1
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.11
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]0
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]1
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.11
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]1
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.11
|
||||
@@ -55,6 +55,7 @@ _MODULE_EXTRAS = {
|
||||
"langextract_tool": ("langextract",),
|
||||
"swarmscore_tool": ("swarmscore",),
|
||||
"composio_tool": ("composio",),
|
||||
"stripe_tool": ("stripe",),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Keenable Search Tool for PraisonAI Agents.
|
||||
|
||||
Keyless (or keyed) web search using Keenable.
|
||||
|
||||
Keenable works with no API key and no extra install: it calls the keyless
|
||||
public endpoint with the Python standard library (``urllib``). Setting
|
||||
``KEENABLE_API_KEY`` switches to the authenticated endpoint for higher limits.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import KeenableSearchTool
|
||||
|
||||
keenable = KeenableSearchTool()
|
||||
results = keenable.search("AI news 2024")
|
||||
|
||||
Environment Variables:
|
||||
KEENABLE_API_KEY: Optional. When set, the authenticated endpoint is used.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.request import HTTPRedirectHandler
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEENABLE_SEARCH_URL = "https://api.keenable.ai/v1/search"
|
||||
KEENABLE_PUBLIC_SEARCH_URL = "https://api.keenable.ai/v1/search/public"
|
||||
KEENABLE_APP_TITLE = "PraisonAI"
|
||||
KEENABLE_SEARCH_TIMEOUT_SECONDS = 30
|
||||
KEENABLE_MAX_RESPONSE_BYTES = 1_000_000
|
||||
KEENABLE_MAX_RESULTS = 50
|
||||
KEENABLE_MAX_TITLE_CHARS = 256
|
||||
KEENABLE_MAX_URL_CHARS = 2048
|
||||
KEENABLE_MAX_SNIPPET_CHARS = 1200
|
||||
|
||||
|
||||
def _truncate_text(value: Any, limit: int) -> str:
|
||||
"""Trim whitespace and cap ``value`` to ``limit`` characters."""
|
||||
text = str(value or "").strip()
|
||||
if len(text) > limit:
|
||||
return text[:limit - 1] + "\u2026"
|
||||
return text
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
"""Redirect handler that refuses to follow any redirect.
|
||||
|
||||
Keenable never redirects. Following a 3xx would resend ``X-API-Key`` to
|
||||
whatever host it points at, so the redirect is dropped (urllib then raises
|
||||
the 3xx as an error, surfaced to the caller) and no second request is sent.
|
||||
"""
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
"""Return ``None`` so urllib does not issue a follow-up request."""
|
||||
return None
|
||||
|
||||
|
||||
class KeenableSearchTool(BaseTool):
|
||||
"""Tool for keyless (or keyed) web search using Keenable."""
|
||||
|
||||
name = "keenable_search"
|
||||
description = "Search the web using Keenable (keyless; an API key raises limits)."
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Create the tool, resolving the API key from the argument or env.
|
||||
|
||||
Args:
|
||||
api_key: Optional Keenable API key. Falls back to the
|
||||
``KEENABLE_API_KEY`` environment variable; keyless if unset.
|
||||
"""
|
||||
self.api_key = (api_key or os.getenv("KEENABLE_API_KEY") or "").strip()
|
||||
super().__init__()
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "search",
|
||||
query: Optional[str] = None,
|
||||
max_results: int = 5,
|
||||
**kwargs,
|
||||
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""Dispatch a Keenable action; only ``search`` is supported."""
|
||||
action = action.lower().replace("-", "_")
|
||||
if action == "search":
|
||||
return self.search(query=query, max_results=max_results)
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
def search(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Keenable.
|
||||
|
||||
Args:
|
||||
query: Search query string.
|
||||
max_results: Maximum results to return (capped at 50).
|
||||
|
||||
Returns:
|
||||
List of dicts with ``title``, ``url``, ``snippet`` and ``provider``,
|
||||
or a single-item list with an ``error`` key on failure.
|
||||
"""
|
||||
if not query:
|
||||
return [{"error": "query is required"}]
|
||||
|
||||
result_limit = max(0, min(int(max_results), KEENABLE_MAX_RESULTS))
|
||||
if result_limit == 0:
|
||||
return []
|
||||
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, build_opener
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "praisonai-tools",
|
||||
# Required on keyless calls; names the app, carries no user identifier.
|
||||
"X-Keenable-Title": KEENABLE_APP_TITLE,
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
body = json.dumps({
|
||||
"query": query,
|
||||
"max_results": result_limit,
|
||||
"snippet_max_length": KEENABLE_MAX_SNIPPET_CHARS,
|
||||
}).encode("utf-8")
|
||||
request = Request(
|
||||
KEENABLE_SEARCH_URL if self.api_key else KEENABLE_PUBLIC_SEARCH_URL,
|
||||
data=body,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
opener = build_opener(_NoRedirect)
|
||||
with opener.open(request, timeout=KEENABLE_SEARCH_TIMEOUT_SECONDS) as response:
|
||||
raw = response.read(KEENABLE_MAX_RESPONSE_BYTES + 1)
|
||||
except URLError as exc:
|
||||
logger.error(f"Keenable search error: {exc}")
|
||||
return [{"error": str(exc)}]
|
||||
except Exception as exc:
|
||||
logger.error(f"Keenable search error: {exc}")
|
||||
return [{"error": str(exc)}]
|
||||
|
||||
if len(raw) > KEENABLE_MAX_RESPONSE_BYTES:
|
||||
return [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
return [{"error": f"Keenable returned invalid JSON: {exc}"}]
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
|
||||
return [{"error": "Keenable returned an invalid response shape"}]
|
||||
|
||||
results = []
|
||||
for result in payload["results"]:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
url = result.get("url")
|
||||
if not isinstance(url, str) or not url or len(url) > KEENABLE_MAX_URL_CHARS:
|
||||
continue
|
||||
# The page text is in `snippet`; `description` is usually empty.
|
||||
snippet = " ".join(str(result.get("snippet") or result.get("description") or "").split())
|
||||
results.append({
|
||||
"title": _truncate_text(result.get("title", ""), KEENABLE_MAX_TITLE_CHARS),
|
||||
"url": url,
|
||||
"snippet": _truncate_text(snippet, KEENABLE_MAX_SNIPPET_CHARS),
|
||||
"provider": "keenable",
|
||||
})
|
||||
if len(results) >= result_limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def keenable_search(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Keenable (keyless; KEENABLE_API_KEY raises limits)."""
|
||||
return KeenableSearchTool().search(query=query, max_results=max_results)
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Stripe Tool for PraisonAI Agents.
|
||||
|
||||
Narrow, audited Stripe operations — retrieve customers/payment intents,
|
||||
list payment links, and create payment links with required correlation
|
||||
metadata and optional idempotency keys.
|
||||
|
||||
Design constraint: prefer narrow, audited operations over a "god tool"
|
||||
that dumps the entire Stripe surface. One verb per action.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import StripeTool
|
||||
|
||||
stripe = StripeTool() # reads STRIPE_SECRET_KEY (or STRIPE_API_KEY)
|
||||
customer = stripe.run(action="get_customer", customer_id="cus_123")
|
||||
link = stripe.run(
|
||||
action="create_payment_link",
|
||||
price_id="price_123",
|
||||
quantity=1,
|
||||
correlation_id="order-42",
|
||||
)
|
||||
|
||||
Environment Variables:
|
||||
STRIPE_SECRET_KEY: Stripe secret key. Restricted keys are recommended.
|
||||
Accepts test (``sk_test_``/``rk_test_``) or live
|
||||
(``sk_live_``/``rk_live_``) keys. STRIPE_API_KEY is also accepted
|
||||
as a fallback.
|
||||
|
||||
Security notes:
|
||||
* Secrets are read from the environment only and never logged.
|
||||
* Responses include a ``mode`` banner (``test``/``live``) derived from
|
||||
the key prefix so live operations are visible to callers.
|
||||
* Structured logs record only the operation, mode, idempotency key, and
|
||||
the Stripe object id — never full request/response payloads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://api.stripe.com/v1"
|
||||
|
||||
_TEST_PREFIXES = ("sk_test_", "rk_test_")
|
||||
_LIVE_PREFIXES = ("sk_live_", "rk_live_")
|
||||
|
||||
|
||||
class StripeTool(BaseTool):
|
||||
"""Tool for narrow, audited Stripe operations."""
|
||||
|
||||
name = "stripe"
|
||||
description = (
|
||||
"Retrieve Stripe customers and payment intents, list payment links, "
|
||||
"and create payment links with required correlation metadata."
|
||||
)
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = (
|
||||
api_key
|
||||
or os.getenv("STRIPE_SECRET_KEY")
|
||||
or os.getenv("STRIPE_API_KEY")
|
||||
)
|
||||
super().__init__()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _mode(self) -> str:
|
||||
"""Return ``test``, ``live``, or ``unknown`` from the key prefix."""
|
||||
key = self.api_key or ""
|
||||
if key.startswith(_LIVE_PREFIXES):
|
||||
return "live"
|
||||
if key.startswith(_TEST_PREFIXES):
|
||||
return "test"
|
||||
return "unknown"
|
||||
|
||||
def _require_key(self) -> Optional[Dict[str, str]]:
|
||||
if not self.api_key:
|
||||
return {"error": "STRIPE_SECRET_KEY (or STRIPE_API_KEY) is required"}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _import_requests():
|
||||
try:
|
||||
import requests
|
||||
return requests
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a request to the Stripe REST API.
|
||||
|
||||
Centralises key validation, the optional ``requests`` import, the
|
||||
HTTP call, auditable logging, and error handling.
|
||||
"""
|
||||
err = self._require_key()
|
||||
if err:
|
||||
return err
|
||||
|
||||
requests = self._import_requests()
|
||||
if requests is None:
|
||||
return {"error": "requests package is not installed"}
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
|
||||
try:
|
||||
resp = requests.request(
|
||||
method,
|
||||
f"{API_BASE}/{endpoint}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=30,
|
||||
)
|
||||
body = resp.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Stripe request error: %s", e)
|
||||
return {"error": f"API request failed: {e}"}
|
||||
except ValueError as e:
|
||||
logger.error("Stripe JSON decode error: %s", e)
|
||||
return {"error": "Failed to decode API response"}
|
||||
|
||||
if isinstance(body, dict) and "error" in body:
|
||||
stripe_err = body["error"]
|
||||
message = stripe_err.get("message", "Stripe API error")
|
||||
logger.error(
|
||||
"Stripe API error: op=%s mode=%s type=%s",
|
||||
endpoint, self._mode(), stripe_err.get("type"),
|
||||
)
|
||||
return {"error": message}
|
||||
|
||||
# Defensive: a non-2xx status without Stripe's usual ``error`` object
|
||||
# must not be treated as a successful payload.
|
||||
if resp.status_code >= 400:
|
||||
logger.error(
|
||||
"Stripe HTTP error: op=%s mode=%s status=%s",
|
||||
endpoint, self._mode(), resp.status_code,
|
||||
)
|
||||
return {"error": f"Stripe API returned HTTP {resp.status_code}"}
|
||||
|
||||
obj_id = body.get("id") if isinstance(body, dict) else None
|
||||
logger.info(
|
||||
"Stripe op=%s mode=%s idempotency_key=%s object_id=%s",
|
||||
endpoint, self._mode(), idempotency_key, obj_id,
|
||||
)
|
||||
return body
|
||||
|
||||
def _banner(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Attach a live/test mode banner to a successful response."""
|
||||
if isinstance(payload, dict) and "error" not in payload:
|
||||
payload = {"mode": self._mode(), **payload}
|
||||
return payload
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "get_customer",
|
||||
customer_id: Optional[str] = None,
|
||||
payment_intent_id: Optional[str] = None,
|
||||
price_id: Optional[str] = None,
|
||||
quantity: int = 1,
|
||||
correlation_id: Optional[str] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
**kwargs,
|
||||
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""Dispatch to the appropriate Stripe action.
|
||||
|
||||
Actions:
|
||||
get_customer — Retrieve a customer by id (read).
|
||||
get_payment_intent — Retrieve a payment intent by id (read).
|
||||
list_payment_links — List payment links (read).
|
||||
create_payment_link — Create a payment link (write, guarded).
|
||||
"""
|
||||
action = action.lower().replace("-", "_")
|
||||
|
||||
if action == "get_customer":
|
||||
return self.get_customer(customer_id=customer_id)
|
||||
elif action == "get_payment_intent":
|
||||
return self.get_payment_intent(payment_intent_id=payment_intent_id)
|
||||
elif action == "list_payment_links":
|
||||
return self.list_payment_links(limit=limit)
|
||||
elif action == "create_payment_link":
|
||||
return self.create_payment_link(
|
||||
price_id=price_id,
|
||||
quantity=quantity,
|
||||
correlation_id=correlation_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 — Read-mostly safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_customer(self, customer_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Retrieve a customer by id.
|
||||
|
||||
Args:
|
||||
customer_id: The Stripe customer id (``cus_...``).
|
||||
"""
|
||||
if not customer_id:
|
||||
return {"error": "customer_id is required"}
|
||||
|
||||
result = self._request("get", f"customers/{customer_id}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"email": result.get("email"),
|
||||
"name": result.get("name"),
|
||||
"created": result.get("created"),
|
||||
"currency": result.get("currency"),
|
||||
"delinquent": result.get("delinquent"),
|
||||
})
|
||||
|
||||
def get_payment_intent(
|
||||
self, payment_intent_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Retrieve a payment intent by id.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The Stripe payment intent id (``pi_...``).
|
||||
"""
|
||||
if not payment_intent_id:
|
||||
return {"error": "payment_intent_id is required"}
|
||||
|
||||
result = self._request("get", f"payment_intents/{payment_intent_id}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"amount": result.get("amount"),
|
||||
"currency": result.get("currency"),
|
||||
"status": result.get("status"),
|
||||
"customer": result.get("customer"),
|
||||
"created": result.get("created"),
|
||||
})
|
||||
|
||||
def list_payment_links(self, limit: int = 10) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""List payment links.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of payment links to return (1–100).
|
||||
"""
|
||||
limit = max(1, min(int(limit), 100))
|
||||
result = self._request("get", f"payment_links?limit={limit}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
links = [
|
||||
{
|
||||
"id": link.get("id"),
|
||||
"url": link.get("url"),
|
||||
"active": link.get("active"),
|
||||
"metadata": link.get("metadata", {}),
|
||||
}
|
||||
for link in result.get("data", [])
|
||||
]
|
||||
return self._banner({"payment_links": links})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 2 — Mutations with guardrails
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_payment_link(
|
||||
self,
|
||||
price_id: Optional[str] = None,
|
||||
quantity: int = 1,
|
||||
correlation_id: Optional[str] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a payment link.
|
||||
|
||||
A ``correlation_id`` is required and stored as payment-link metadata
|
||||
so every created link is traceable back to the request that made it.
|
||||
An optional ``idempotency_key`` is surfaced to make retries safe.
|
||||
|
||||
Args:
|
||||
price_id: An existing Stripe Price id (``price_...``).
|
||||
quantity: Quantity for the line item (>= 1).
|
||||
correlation_id: Required correlation id stored as metadata.
|
||||
idempotency_key: Optional Stripe ``Idempotency-Key`` header value.
|
||||
"""
|
||||
if not price_id:
|
||||
return {"error": "price_id is required"}
|
||||
if not correlation_id:
|
||||
return {"error": "correlation_id is required (stored as metadata)"}
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
except (TypeError, ValueError):
|
||||
return {"error": "quantity must be an integer"}
|
||||
if quantity < 1:
|
||||
return {"error": "quantity must be >= 1"}
|
||||
|
||||
data = {
|
||||
"line_items[0][price]": price_id,
|
||||
"line_items[0][quantity]": quantity,
|
||||
"metadata[correlation_id]": correlation_id,
|
||||
}
|
||||
result = self._request(
|
||||
"post",
|
||||
"payment_links",
|
||||
data=data,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"url": result.get("url"),
|
||||
"active": result.get("active"),
|
||||
"metadata": result.get("metadata", {}),
|
||||
})
|
||||
|
||||
|
||||
def get_stripe_customer(
|
||||
customer_id: str, api_key: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Retrieve a Stripe customer by id."""
|
||||
return StripeTool(api_key=api_key).get_customer(customer_id=customer_id)
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-tools"
|
||||
version = "0.2.43"
|
||||
version = "0.2.45"
|
||||
description = "Extended tools for PraisonAI Agents"
|
||||
authors = [
|
||||
{name = "Mervin Praison"}
|
||||
@@ -85,6 +85,9 @@ meeting = [
|
||||
"openai>=1.12.0",
|
||||
"chromadb>=0.4.0",
|
||||
]
|
||||
stripe = [
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://docs.praison.ai"
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Unit tests for KeenableSearchTool."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.keenable_search_tool import (
|
||||
KEENABLE_MAX_RESULTS,
|
||||
KEENABLE_MAX_SNIPPET_CHARS,
|
||||
KEENABLE_MAX_TITLE_CHARS,
|
||||
KEENABLE_MAX_URL_CHARS,
|
||||
KEENABLE_PUBLIC_SEARCH_URL,
|
||||
KEENABLE_SEARCH_URL,
|
||||
KeenableSearchTool,
|
||||
_NoRedirect,
|
||||
keenable_search,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for an ``http.client.HTTPResponse`` context manager."""
|
||||
|
||||
def __init__(self, raw: bytes):
|
||||
self._raw = raw
|
||||
|
||||
def read(self, amt=None):
|
||||
"""Return at most ``amt`` bytes, honouring the caller's read cap."""
|
||||
if amt is None:
|
||||
return self._raw
|
||||
return self._raw[:amt]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
def _payload(results):
|
||||
return json.dumps({"results": results}).encode("utf-8")
|
||||
|
||||
|
||||
# ── Import + schema (CI smoke) ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportAndSchema:
|
||||
def test_import_via_package(self):
|
||||
from praisonai_tools import KeenableSearchTool as PkgTool
|
||||
|
||||
assert PkgTool is KeenableSearchTool
|
||||
|
||||
def test_schema_generation(self):
|
||||
tool = KeenableSearchTool()
|
||||
schema = tool.get_schema()
|
||||
assert schema["function"]["name"] == "keenable_search"
|
||||
assert "description" in schema["function"]
|
||||
assert "action" in schema["function"]["parameters"]["properties"]
|
||||
|
||||
|
||||
# ── Endpoint selection ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEndpointSelection:
|
||||
def test_public_endpoint_without_key(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = KeenableSearchTool()
|
||||
assert tool.api_key == ""
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("python", max_results=3)
|
||||
|
||||
assert captured["url"] == KEENABLE_PUBLIC_SEARCH_URL
|
||||
header_names = {k.lower() for k in captured["headers"]}
|
||||
assert "x-api-key" not in header_names
|
||||
assert "X-keenable-title" in captured["headers"] or "X-Keenable-Title" in captured["headers"]
|
||||
|
||||
def test_authenticated_endpoint_with_key(self):
|
||||
tool = KeenableSearchTool(api_key="secret-key")
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("python", max_results=3)
|
||||
|
||||
assert captured["url"] == KEENABLE_SEARCH_URL
|
||||
# urllib title-cases header keys.
|
||||
assert captured["headers"].get("X-api-key") == "secret-key"
|
||||
|
||||
|
||||
# ── Result mapping + bounds ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResultMapping:
|
||||
def _run(self, tool, results):
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload(results))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
return tool.search("q", max_results=10)
|
||||
|
||||
def test_maps_fields(self):
|
||||
out = self._run(
|
||||
KeenableSearchTool(),
|
||||
[{"title": "T", "url": "https://example.com", "snippet": "hello world"}],
|
||||
)
|
||||
assert out == [{
|
||||
"title": "T",
|
||||
"url": "https://example.com",
|
||||
"snippet": "hello world",
|
||||
"provider": "keenable",
|
||||
}]
|
||||
|
||||
def test_skips_entries_without_url(self):
|
||||
out = self._run(
|
||||
KeenableSearchTool(),
|
||||
[{"title": "no url"}, {"title": "ok", "url": "https://a.com"}],
|
||||
)
|
||||
assert len(out) == 1
|
||||
assert out[0]["url"] == "https://a.com"
|
||||
|
||||
def test_respects_max_results(self):
|
||||
tool = KeenableSearchTool()
|
||||
many = [{"url": f"https://a.com/{i}"} for i in range(10)]
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload(many))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q", max_results=3)
|
||||
assert len(out) == 3
|
||||
|
||||
def test_zero_max_results_short_circuits(self):
|
||||
out = KeenableSearchTool().search("q", max_results=0)
|
||||
assert out == []
|
||||
|
||||
def test_empty_query_errors(self):
|
||||
out = KeenableSearchTool().search("", max_results=5)
|
||||
assert out == [{"error": "query is required"}]
|
||||
|
||||
|
||||
# ── Error handling ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_invalid_shape_errors(self):
|
||||
tool = KeenableSearchTool()
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(b'{"not_results": 1}')
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out and "error" in out[0]
|
||||
|
||||
def test_oversized_response_errors(self):
|
||||
tool = KeenableSearchTool()
|
||||
oversized = b"x" * (1_000_001 + 1)
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(oversized)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
def test_http_error_is_captured(self):
|
||||
tool = KeenableSearchTool()
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = RuntimeError("boom")
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "boom"}]
|
||||
|
||||
|
||||
# ── Protective bounds + redirect refusal ────────────────────────────
|
||||
|
||||
|
||||
class TestProtections:
|
||||
def _capture_body(self, tool, requested):
|
||||
"""Run a search and return the decoded JSON request body sent."""
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["body"] = json.loads(request.data.decode("utf-8"))
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("q", max_results=requested)
|
||||
return captured["body"]
|
||||
|
||||
def test_caps_requested_results_at_limit(self):
|
||||
"""max_results above the hard cap is clamped before the request."""
|
||||
body = self._capture_body(KeenableSearchTool(), requested=1000)
|
||||
assert body["max_results"] == KEENABLE_MAX_RESULTS
|
||||
|
||||
def test_truncates_oversized_title(self):
|
||||
"""Titles longer than the cap are truncated with an ellipsis."""
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"title": "T" * 5000, "url": "https://a.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert len(out[0]["title"]) == KEENABLE_MAX_TITLE_CHARS
|
||||
assert out[0]["title"].endswith("…")
|
||||
|
||||
def test_truncates_oversized_snippet(self):
|
||||
"""Snippets longer than the cap are truncated with an ellipsis."""
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"snippet": "s " * 5000, "url": "https://a.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert len(out[0]["snippet"]) == KEENABLE_MAX_SNIPPET_CHARS
|
||||
assert out[0]["snippet"].endswith("…")
|
||||
|
||||
def test_skips_oversized_url(self):
|
||||
"""Results whose URL exceeds the length cap are dropped."""
|
||||
long_url = "https://a.com/" + "x" * KEENABLE_MAX_URL_CHARS
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"url": long_url}, {"url": "https://ok.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert out == [{
|
||||
"title": "",
|
||||
"url": "https://ok.com",
|
||||
"snippet": "",
|
||||
"provider": "keenable",
|
||||
}]
|
||||
|
||||
def test_read_is_capped_at_limit(self):
|
||||
"""The body read is bounded, so an oversized stream still errors out."""
|
||||
tool = KeenableSearchTool()
|
||||
# Far larger than the cap; _FakeResponse.read honours the byte count,
|
||||
# so this verifies the read limit rather than a pre-sized buffer.
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(b"x" * 5_000_000)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
def test_refuses_redirects(self):
|
||||
"""The redirect handler drops 3xx responses so no follow-up is sent."""
|
||||
handler = _NoRedirect()
|
||||
# Returning None is urllib's signal to NOT build/send a second request.
|
||||
assert handler.redirect_request(
|
||||
MagicMock(), MagicMock(), 302, "Found", {}, "https://evil.example"
|
||||
) is None
|
||||
|
||||
|
||||
# ── Module-level convenience function ───────────────────────────────
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
def test_keenable_search_function(self):
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload([{"url": "https://a.com"}]))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = keenable_search("q", max_results=2)
|
||||
assert out[0]["provider"] == "keenable"
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Unit tests for StripeTool."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.stripe_tool import StripeTool, get_stripe_customer
|
||||
|
||||
|
||||
def _mock_response(payload, status_code=200):
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = payload
|
||||
resp.status_code = status_code
|
||||
return resp
|
||||
|
||||
|
||||
# ── Key handling / mode banner ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestKeyAndMode:
|
||||
def test_env_secret_key_fallback(self):
|
||||
with patch.dict(os.environ, {"STRIPE_SECRET_KEY": "sk_test_x"}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.api_key == "sk_test_x"
|
||||
assert tool._mode() == "test"
|
||||
|
||||
def test_stripe_api_key_fallback(self):
|
||||
with patch.dict(os.environ, {"STRIPE_API_KEY": "sk_live_x"}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.api_key == "sk_live_x"
|
||||
assert tool._mode() == "live"
|
||||
|
||||
def test_restricted_key_prefixes(self):
|
||||
assert StripeTool(api_key="rk_test_x")._mode() == "test"
|
||||
assert StripeTool(api_key="rk_live_x")._mode() == "live"
|
||||
|
||||
def test_unknown_mode(self):
|
||||
assert StripeTool(api_key="weird")._mode() == "unknown"
|
||||
|
||||
def test_missing_key_returns_error(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.get_customer(customer_id="cus_1") == {
|
||||
"error": "STRIPE_SECRET_KEY (or STRIPE_API_KEY) is required"
|
||||
}
|
||||
|
||||
|
||||
# ── get_customer ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetCustomer:
|
||||
def test_requires_customer_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.get_customer(customer_id="") == {"error": "customer_id is required"}
|
||||
|
||||
def test_returns_normalised_customer_with_banner(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "cus_1",
|
||||
"email": "[email protected]",
|
||||
"name": "Alice",
|
||||
"created": 123,
|
||||
"currency": "usd",
|
||||
"delinquent": False,
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)) as req:
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
|
||||
args, kwargs = req.call_args
|
||||
assert args[0] == "get"
|
||||
assert args[1].endswith("/customers/cus_1")
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer sk_test_x"
|
||||
assert result["mode"] == "test"
|
||||
assert result["id"] == "cus_1"
|
||||
assert result["email"] == "[email protected]"
|
||||
|
||||
def test_propagates_stripe_error(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {"error": {"message": "No such customer", "type": "invalid_request_error"}}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
assert tool.get_customer(customer_id="cus_x") == {"error": "No such customer"}
|
||||
|
||||
def test_handles_request_exception(self):
|
||||
import requests
|
||||
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch(
|
||||
"requests.request",
|
||||
side_effect=requests.exceptions.RequestException("boom"),
|
||||
):
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
assert result["error"].startswith("API request failed")
|
||||
|
||||
def test_non_2xx_without_error_object_is_failure(self):
|
||||
# A non-2xx response whose body lacks Stripe's usual ``error`` object
|
||||
# must not be treated as a successful payload.
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch(
|
||||
"requests.request",
|
||||
return_value=_mock_response({"unexpected": "shape"}, status_code=502),
|
||||
):
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
assert result == {"error": "Stripe API returned HTTP 502"}
|
||||
|
||||
|
||||
# ── get_payment_intent ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetPaymentIntent:
|
||||
def test_requires_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.get_payment_intent(payment_intent_id="") == {
|
||||
"error": "payment_intent_id is required"
|
||||
}
|
||||
|
||||
def test_returns_normalised_intent(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "pi_1",
|
||||
"amount": 500,
|
||||
"currency": "usd",
|
||||
"status": "succeeded",
|
||||
"customer": "cus_1",
|
||||
"created": 1,
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
result = tool.get_payment_intent(payment_intent_id="pi_1")
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["amount"] == 500
|
||||
assert result["mode"] == "test"
|
||||
|
||||
|
||||
# ── list_payment_links ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListPaymentLinks:
|
||||
def test_returns_links(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "plink_1", "url": "https://pay/1", "active": True, "metadata": {}},
|
||||
{"id": "plink_2", "url": "https://pay/2", "active": False, "metadata": {}},
|
||||
]
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
result = tool.list_payment_links(limit=2)
|
||||
assert result["mode"] == "test"
|
||||
assert len(result["payment_links"]) == 2
|
||||
assert result["payment_links"][0]["id"] == "plink_1"
|
||||
|
||||
def test_clamps_limit(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch("requests.request", return_value=_mock_response({"data": []})) as req:
|
||||
tool.list_payment_links(limit=999)
|
||||
assert "limit=100" in req.call_args[0][1]
|
||||
|
||||
def test_propagates_error(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {"error": {"message": "bad"}}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
assert tool.list_payment_links() == {"error": "bad"}
|
||||
|
||||
|
||||
# ── create_payment_link (guardrails) ────────────────────────────────
|
||||
|
||||
|
||||
class TestCreatePaymentLink:
|
||||
def test_requires_price_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(
|
||||
price_id="", correlation_id="c1"
|
||||
) == {"error": "price_id is required"}
|
||||
|
||||
def test_requires_correlation_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(price_id="price_1") == {
|
||||
"error": "correlation_id is required (stored as metadata)"
|
||||
}
|
||||
|
||||
def test_rejects_bad_quantity(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(
|
||||
price_id="price_1", correlation_id="c1", quantity=0
|
||||
) == {"error": "quantity must be >= 1"}
|
||||
|
||||
def test_success_sends_metadata_and_idempotency_key(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "plink_1",
|
||||
"url": "https://pay/1",
|
||||
"active": True,
|
||||
"metadata": {"correlation_id": "order-42"},
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)) as req:
|
||||
result = tool.create_payment_link(
|
||||
price_id="price_1",
|
||||
quantity=2,
|
||||
correlation_id="order-42",
|
||||
idempotency_key="idem-1",
|
||||
)
|
||||
|
||||
args, kwargs = req.call_args
|
||||
assert args[0] == "post"
|
||||
assert args[1].endswith("/payment_links")
|
||||
assert kwargs["data"]["line_items[0][price]"] == "price_1"
|
||||
assert kwargs["data"]["line_items[0][quantity]"] == 2
|
||||
assert kwargs["data"]["metadata[correlation_id]"] == "order-42"
|
||||
assert kwargs["headers"]["Idempotency-Key"] == "idem-1"
|
||||
assert result["id"] == "plink_1"
|
||||
assert result["mode"] == "test"
|
||||
|
||||
|
||||
# ── run() dispatcher ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunDispatcher:
|
||||
def test_unknown_action(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.run(action="bogus") == {"error": "Unknown action: bogus"}
|
||||
|
||||
def test_routes_get_customer(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch.object(tool, "get_customer", return_value={"ok": True}) as m:
|
||||
tool.run(action="get_customer", customer_id="cus_1")
|
||||
m.assert_called_once_with(customer_id="cus_1")
|
||||
|
||||
def test_routes_create_payment_link(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch.object(tool, "create_payment_link", return_value={"ok": True}) as m:
|
||||
tool.run(
|
||||
action="create-payment-link",
|
||||
price_id="price_1",
|
||||
quantity=1,
|
||||
correlation_id="c1",
|
||||
idempotency_key="i1",
|
||||
)
|
||||
m.assert_called_once_with(
|
||||
price_id="price_1", quantity=1, correlation_id="c1", idempotency_key="i1"
|
||||
)
|
||||
|
||||
|
||||
# ── Module-level helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHelper:
|
||||
def test_delegates_to_tool(self):
|
||||
with patch.object(StripeTool, "get_customer", return_value={"id": "cus_1"}) as m:
|
||||
assert get_stripe_customer("cus_1", api_key="sk_test_x") == {"id": "cus_1"}
|
||||
m.assert_called_once_with(customer_id="cus_1")
|
||||
Reference in new issue
Block a user