Files
magnus919_agent-skills/crm/tests/test_crm_cli.py
T
Magnus HedemarkGitHubfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
3256a87bcb feat(skill): add collaboration & business-app tool layer (Slack, Notion, email, CRM, payments) (#269)
Adds five top-level operational tool skills, one per named tool:

- slack: messages, channels, threads, search, files, and webhook signature
  verification (HMAC-SHA256) via a bounded, stdlib-only slack-cli.
- notion: pages, database queries, search, and guarded page updates via
  notion-cli.
- email: transactional email via Twilio SendGrid (send, deliverability
  bounces/spam reports, Signed Event Webhook verification with a
  self-contained ECDSA P-256 verifier) via email-cli.
- crm: HubSpot CRM records, contact search, and deal pipeline views with
  guarded stage updates via crm-cli.
- stripe: read-only-first balance, payment, and subscription queries with
  a guarded period-end subscription cancellation via stripe-cli.

Each skill ships an executable script (--json output, --limit bounded reads,
--dry-run/--yes mutation gate), a human README with the five required
sections, a schema-v1 evals/evals.json with six output-quality cases, a dated
source index + operations reference, and a deterministic unittest suite run
by check-artifacts. All five are indexed in the top-level README and the
generated catalogs were regenerated. Eval coverage rises from 78/139 to
83/144.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 20:19:26 -04:00

229 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""Deterministic tests for crm/scripts/crm-cli.
Runs the script as a subprocess so the tests exercise the real CLI surface
(--help, --json, --limit, mutation gate, exit codes, JSON payloads). A local
stdlib HTTP server stubs the HubSpot CRM v3 API (contacts, deals, pipelines,
search), so no external network or HubSpot account is needed. Also asserts the
read-only contract: reads never call write methods, and the mutation gate
refuses to move a deal without --dry-run or --yes.
"""
import json
import os
import subprocess
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "crm-cli"
CONTACT = {"id": "51", "properties": {"firstname": "Ada", "lastname": "Lovelace",
"email": "ada@example.com", "company": "Analytical",
"createdate": "2026-01-01T00:00:00Z"}}
DEAL = {"id": "901", "properties": {"dealname": "Acme renewal", "amount": "12000",
"pipeline": "default", "dealstage": "appointmentscheduled",
"hs_lastmodifieddate": "2026-01-02T00:00:00Z"}}
PIPELINE = {"id": "default", "label": "Default pipeline",
"stages": [{"id": "appointmentscheduled", "label": "Appointment Scheduled",
"displayOrder": 0}]}
class StubHubSpotServer:
"""Minimal stub of the HubSpot CRM v3 API surface used by crm-cli."""
def __init__(self):
self.requests = [] # (method, path, body)
handler = self._make_handler()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
def _make_handler(self):
stub = self
class Handler(BaseHTTPRequestHandler):
def _read_body(self):
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
return json.loads(raw.decode("utf-8")) if raw else {}
def _json(self, payload, status=200):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(payload).encode("utf-8"))
def do_GET(self): # noqa: N802
stub.requests.append(("GET", self.path, None))
if "/objects/contacts?" in self.path:
self._json({"results": [CONTACT], "total": 1, "paging": {}})
elif "/objects/contacts/" in self.path:
self._json(CONTACT)
elif "/objects/deals?" in self.path:
self._json({"results": [DEAL], "total": 1, "paging": {}})
elif "/pipelines/deals" in self.path:
self._json({"results": [PIPELINE]})
else:
self._json({"message": "not_found"}, 404)
def do_POST(self): # noqa: N802
body = self._read_body()
stub.requests.append(("POST", self.path, body))
if "/search" in self.path:
limit = body.get("limit", 20)
self._json({"results": [CONTACT][:limit], "total": 1})
else:
self._json({"message": "not_found"}, 404)
def do_PATCH(self): # noqa: N802
body = self._read_body()
stub.requests.append(("PATCH", self.path, body))
if "/objects/deals/" in self.path:
updated = json.loads(json.dumps(DEAL))
updated["properties"]["dealstage"] = body.get("properties", {}).get("dealstage")
self._json(updated)
else:
self._json({"message": "not_found"}, 404)
def log_message(self, *args): # silence stderr
pass
return Handler
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.server.shutdown()
self.server.server_close()
def run_script(env, *args):
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True,
text=True,
timeout=30,
env=env,
)
def base_env(stub):
env = dict(os.environ)
env["HUBSPOT_TOKEN"] = "pat-test"
env["HUBSPOT_API_BASE"] = f"http://127.0.0.1:{stub.port}/"
return env
def load_json(proc):
return json.loads(proc.stdout)
class CrmCliTests(unittest.TestCase):
def test_help_lists_json_and_bounded_reads(self):
proc = run_script(dict(os.environ), "--help")
self.assertEqual(proc.returncode, 0)
self.assertIn("--json", proc.stdout)
self.assertIn("--limit", proc.stdout)
def test_help_works_without_token(self):
env = dict(os.environ)
env.pop("HUBSPOT_TOKEN", None)
proc = run_script(env, "contacts", "list", "--help")
self.assertEqual(proc.returncode, 0)
def test_contacts_list(self):
with StubHubSpotServer() as stub:
proc = run_script(base_env(stub), "--json", "--limit", "5", "contacts", "list")
self.assertEqual(proc.returncode, 0, proc.stderr)
data = load_json(proc)
self.assertEqual(data["contacts"][0]["email"], "ada@example.com")
def test_contacts_get(self):
with StubHubSpotServer() as stub:
proc = run_script(base_env(stub), "--json", "contacts", "get", "--id", "51")
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(load_json(proc)["contact"]["id"], "51")
def test_contacts_search(self):
with StubHubSpotServer() as stub:
proc = run_script(base_env(stub), "--json", "contacts", "search", "--query", "ada")
self.assertEqual(proc.returncode, 0, proc.stderr)
data = load_json(proc)
self.assertEqual(data["total"], 1)
self.assertEqual(data["contacts"][0]["firstname"], "Ada")
def test_search_sends_limit(self):
with StubHubSpotServer() as stub:
run_script(base_env(stub), "--json", "--limit", "3", "contacts", "search",
"--query", "ada")
posts = [body for method, path, body in stub.requests
if method == "POST" and "/search" in path]
self.assertEqual(len(posts), 1)
self.assertEqual(posts[0].get("limit"), 3)
def test_deals_list_pipeline_view(self):
with StubHubSpotServer() as stub:
proc = run_script(base_env(stub), "--json", "deals", "list",
"--pipeline", "default", "--stage", "appointmentscheduled")
self.assertEqual(proc.returncode, 0, proc.stderr)
data = load_json(proc)
self.assertEqual(data["deals"][0]["dealname"], "Acme renewal")
gets = [path for method, path, _ in stub.requests if method == "GET"]
self.assertTrue(any("objects/deals?" in path for path in gets))
def test_pipelines_list(self):
with StubHubSpotServer() as stub:
proc = run_script(base_env(stub), "--json", "pipelines", "list")
self.assertEqual(proc.returncode, 0, proc.stderr)
data = load_json(proc)
self.assertEqual(data["pipelines"][0]["id"], "default")
self.assertEqual(data["pipelines"][0]["stages"][0]["id"], "appointmentscheduled")
def test_update_stage_requires_confirmation(self):
with StubHubSpotServer() as stub:
proc = run_script(base_env(stub), "deals", "update-stage", "--id", "901",
"--stage", "closedwon")
self.assertEqual(proc.returncode, 1)
self.assertIn("refusing to move", proc.stderr)
self.assertEqual(stub.requests, [], "no API call may be made without confirmation")
def test_update_stage_dry_run_does_not_patch(self):
with StubHubSpotServer() as stub:
proc = run_script(base_env(stub), "--json", "deals", "update-stage", "--id", "901",
"--stage", "closedwon", "--dry-run")
self.assertEqual(proc.returncode, 0, proc.stderr)
data = load_json(proc)
self.assertTrue(data["dry_run"])
self.assertEqual(stub.requests, [], "dry-run must not reach the API")
def test_update_stage_with_yes_patches(self):
with StubHubSpotServer() as stub:
proc = run_script(base_env(stub), "--json", "deals", "update-stage", "--id", "901",
"--stage", "closedwon", "--yes")
self.assertEqual(proc.returncode, 0, proc.stderr)
data = load_json(proc)
self.assertEqual(data["deal"]["dealstage"], "closedwon")
self.assertTrue(any(method == "PATCH" for method, _path, _body in stub.requests))
def test_missing_token_errors_cleanly(self):
env = dict(os.environ)
env.pop("HUBSPOT_TOKEN", None)
proc = run_script(env, "--json", "contacts", "list")
self.assertEqual(proc.returncode, 1)
self.assertIn("HUBSPOT_TOKEN", proc.stdout)
def test_read_only_contract_no_write_opens(self):
source = SCRIPT.read_text()
writes = [line for line in source.splitlines()
if line.strip().startswith("open(") and ("'w'" in line or '"w"' in line)]
self.assertEqual(writes, [], "script must never open files in write mode")
if __name__ == "__main__":
unittest.main()